ProofedMesh/services/dummy-sink/internal/handover/builder.go
CTO (Paperclip) b45ad34169
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
dummy-sink: Phase-1 mock sink (sink:dummy-sink) (DEV-611)
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>
2026-10-25 20:20:40 +00:00

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
}