All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
build-images / signing-service image (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m13s
Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:
- valid=true → atomic .tmp+rename write of payload to
/data/dummy-sink/<decision_id>.json, builds a
basicstack.de/attestations/handover/v1 statement, signs via
signing-service with the double identity flow (Pocket-ID bearer
+ projected SA token), submits DSSE envelope to the TLog and
returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.
Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.
Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403
Co-Authored-By: Paperclip <noreply@paperclip.ing>
95 lines
3 KiB
Go
95 lines
3 KiB
Go
// Package handover builds the in-toto v1 Statement for the Phase-1
|
|
// handover attestation (predicateType =
|
|
// https://basicstack.de/attestations/handover/v1). The attestation
|
|
// records that `sink:dummy-sink` persisted `payload` (identified by
|
|
// `payload_hash`) on behalf of the (approval_ref, transform_ref) chain,
|
|
// per §7.0 of the architecture.
|
|
package handover
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/dummy-sink/internal/signer"
|
|
)
|
|
|
|
// PredicateType is the stable URL the signing-service, verification
|
|
// chain, and transparency-log key the handover statement off of.
|
|
const PredicateType = "https://basicstack.de/attestations/handover/v1"
|
|
|
|
// Input captures everything the sink knows about a successfully
|
|
// persisted delivery.
|
|
type Input struct {
|
|
DecisionID string
|
|
PayloadHash string // sha256:<hex>
|
|
ApprovalRef string
|
|
TransformRef string
|
|
|
|
SinkID string // e.g. "sink:dummy-sink"
|
|
SinkRecordRef string // storage locator (e.g. "file:///data/dummy-sink/<decision_id>.json")
|
|
Actor string // agent registry id of the dummy-sink identity
|
|
|
|
IssuedAt time.Time
|
|
}
|
|
|
|
// Build returns the in-toto Statement handed to the signing-service.
|
|
// The subject binds the attestation to the payload hash so a downstream
|
|
// verifier can detect any tampering between what the agent sent and what
|
|
// the sink persisted.
|
|
func Build(in Input) (signer.Statement, error) {
|
|
if in.DecisionID == "" || in.PayloadHash == "" ||
|
|
in.ApprovalRef == "" || in.TransformRef == "" ||
|
|
in.SinkID == "" || in.SinkRecordRef == "" || in.Actor == "" {
|
|
return nil, errors.New("handover.Build: Input missing required field")
|
|
}
|
|
hashHex, err := StripSha256Prefix(in.PayloadHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
issuedAt := in.IssuedAt
|
|
if issuedAt.IsZero() {
|
|
issuedAt = time.Now()
|
|
}
|
|
return signer.Statement{
|
|
"_type": "https://in-toto.io/Statement/v1",
|
|
"subject": []any{
|
|
map[string]any{
|
|
"name": "handover/" + in.DecisionID,
|
|
"digest": map[string]any{
|
|
"sha256": hashHex,
|
|
},
|
|
},
|
|
},
|
|
"predicateType": PredicateType,
|
|
"predicate": map[string]any{
|
|
"decision_id": in.DecisionID,
|
|
"payload_hash": in.PayloadHash,
|
|
"approval_ref": in.ApprovalRef,
|
|
"transform_ref": in.TransformRef,
|
|
"sink_id": in.SinkID,
|
|
"sink_record_ref": in.SinkRecordRef,
|
|
"materials": []any{
|
|
map[string]any{
|
|
"name": "approval/" + in.ApprovalRef,
|
|
"attestation_ref": in.ApprovalRef,
|
|
},
|
|
map[string]any{
|
|
"name": "transform/" + in.TransformRef,
|
|
"attestation_ref": in.TransformRef,
|
|
},
|
|
},
|
|
"actor": in.Actor,
|
|
"issued_at": issuedAt.UTC().Format(time.RFC3339),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// StripSha256Prefix normalises a `sha256:<hex>` hash to the bare hex
|
|
// subject-digest form in-toto uses.
|
|
func StripSha256Prefix(hash string) (string, error) {
|
|
const prefix = "sha256:"
|
|
if len(hash) <= len(prefix) || hash[:len(prefix)] != prefix {
|
|
return "", errors.New("handover: payload_hash must be sha256:<hex>")
|
|
}
|
|
return hash[len(prefix):], nil
|
|
}
|