96 lines
3 KiB
Go
96 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
|
||
|
|
}
|