ProofedMesh/services/dummy-sink/internal/handover/builder.go

96 lines
3 KiB
Go
Raw Permalink Normal View History

// 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
}