// 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: ApprovalRef string TransformRef string SinkID string // e.g. "sink:dummy-sink" SinkRecordRef string // storage locator (e.g. "file:///data/dummy-sink/.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:` 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:") } return hash[len(prefix):], nil }