// Package attest signs rate_change events and submits the resulting DSSE // envelope to the Transparency Log. Current state: HTTP clients fully wired // against the API contract from ADR-0005. Both services (signing-service, // transparency-log) are still to be deployed; the clients are already usable // against httptest servers and will plug into real services unchanged. package attest import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "time" "basicstack.de/proofedmesh/services/admin-api/internal/model" ) // Statement is the in-toto v1 Statement we hand to the Signing Service. // We serialise it to the Signing Service as the request's `statement` field. type Statement struct { Type string `json:"_type"` Subject []Subject `json:"subject"` PredicateType string `json:"predicateType"` Predicate map[string]any `json:"predicate"` } type Subject struct { Name string `json:"name"` Digest map[string]string `json:"digest"` } // Envelope is the DSSE envelope returned by the Signing Service. type Envelope struct { PayloadType string `json:"payloadType"` Payload string `json:"payload"` // base64url Signatures []EnvelopeSignature `json:"signatures"` } type EnvelopeSignature struct { KeyID string `json:"keyid"` Sig string `json:"sig"` } // Signer wraps the "POST /api/v1/sign/dsse" call. Returned content_hash is the // SHA-256 of the serialised envelope; it IS the attestation reference used // everywhere else in the system. type Signer interface { SignDSSE(ctx context.Context, keyRef string, stmt Statement) (envelope Envelope, contentHash string, err error) } // Submitter wraps the "POST /api/v1/log/entries" call on the Transparency Log. // MUST be idempotent: calling twice with the same (envelope, content_hash) // returns the same log_index and the same inclusion proof. type Submitter interface { Submit(ctx context.Context, env Envelope, contentHash string) (LogReceipt, error) } // LogReceipt is what the Transparency Log hands us back. type LogReceipt struct { LogIndex int64 `json:"log_index"` ContentHash string `json:"content_hash"` LoggedAt time.Time `json:"logged_at"` InclusionProof InclusionProof `json:"inclusion_proof"` } type InclusionProof struct { TreeSize int64 `json:"tree_size"` TreeRootHash string `json:"tree_root_hash"` SignedTreeHeadRef string `json:"signed_tree_head_ref"` Hashes []string `json:"hashes"` } // ChangeKind labels why a rate changed. Mirrored into the predicate so that // the Verification Service can reason about it later. type ChangeKind string const ( ChangeCreate ChangeKind = "create" ChangeRetire ChangeKind = "retire" ) // RateChangeEvent is what the admin-api hands to SignAndLog on every mutation. type RateChangeEvent struct { Kind ChangeKind Rate model.Rate PerformedBy PerformedBy PerformedAt time.Time } type PerformedBy struct { Issuer string `json:"iss"` Subject string `json:"sub"` SessionID string `json:"session_id"` } // SignAndLog orchestrates the Signing Service call and the Transparency Log // submit. Returns the attestation reference (sha256:) on success. If // signing succeeds but logging fails, SignAndLog returns the error AND the // (partial) envelope so a reconciliation worker can retry just the submit. type Orchestrator struct { Signer Signer Submitter Submitter KeyRef string // the signing key reference, e.g. "trust-api/rate-change-signer-2026-q4" } func (o Orchestrator) SignAndLog(ctx context.Context, ev RateChangeEvent) (string, LogReceipt, error) { if o.Signer == nil { return "", LogReceipt{}, errors.New("attest.Orchestrator: Signer is nil") } if o.Submitter == nil { return "", LogReceipt{}, errors.New("attest.Orchestrator: Submitter is nil") } if o.KeyRef == "" { return "", LogReceipt{}, errors.New("attest.Orchestrator: KeyRef is empty") } stmt, err := buildStatement(ev) if err != nil { return "", LogReceipt{}, fmt.Errorf("build statement: %w", err) } env, contentHash, err := o.Signer.SignDSSE(ctx, o.KeyRef, stmt) if err != nil { return "", LogReceipt{}, fmt.Errorf("sign dsse: %w", err) } receipt, err := o.Submitter.Submit(ctx, env, contentHash) if err != nil { return contentHash, LogReceipt{}, fmt.Errorf("submit tlog: %w", err) } if receipt.ContentHash != contentHash { return contentHash, receipt, fmt.Errorf("tlog returned content_hash %q, expected %q", receipt.ContentHash, contentHash) } return contentHash, receipt, nil } // buildStatement produces a canonical in-toto v1 Statement for a rate change. // The subject.name encodes rate_id for immediate human readability; the // subject.digest is the SHA-256 of the rate's content-defining fields so that // verifiers can rehash the stored row and confirm the signature covers it. func buildStatement(ev RateChangeEvent) (Statement, error) { rateHash, err := hashRateCore(ev.Rate) if err != nil { return Statement{}, err } predicate := map[string]any{ "change_kind": string(ev.Kind), "rate_id": ev.Rate.RateID, "country": ev.Rate.Country, "city": ev.Rate.City, "currency": ev.Rate.Currency, "full_day": ev.Rate.FullDay, "partial_day_min8h": ev.Rate.PartialDayMin8h, "arrival_day": ev.Rate.ArrivalDay, "departure_day": ev.Rate.DepartureDay, "valid_from": ev.Rate.ValidFrom.Format("2006-01-02"), "valid_to": nilDate(ev.Rate.ValidTo), "source_regulation": ev.Rate.SourceRegulation, "performed_by": ev.PerformedBy, "performed_at": ev.PerformedAt.UTC().Format(time.RFC3339), } return Statement{ Type: "https://in-toto.io/Statement/v1", Subject: []Subject{{ Name: fmt.Sprintf("per_diem_rate/%s", ev.Rate.RateID), Digest: map[string]string{"sha256": rateHash}, }}, PredicateType: "https://basicstack.de/attestations/rate_change/v1", Predicate: predicate, }, nil } func nilDate(t *time.Time) any { if t == nil { return nil } return t.Format("2006-01-02") } // hashRateCore computes a stable SHA-256 over the fields that identify a rate. // The exclusion of valid_to and the attestation ref itself is deliberate: both // may legitimately change later (retire + attach_attestation) and must not // invalidate the historical signature. func hashRateCore(r model.Rate) (string, error) { core := map[string]any{ "rate_id": r.RateID, "country": r.Country, "city": r.City, "currency": r.Currency, "full_day": r.FullDay, "partial_day_min8h": r.PartialDayMin8h, "arrival_day": r.ArrivalDay, "departure_day": r.DepartureDay, "valid_from": r.ValidFrom.Format("2006-01-02"), "source_regulation": r.SourceRegulation, } b, err := canonicalJSON(core) if err != nil { return "", err } return "sha256:" + sha256hex(b), nil } // canonicalJSON writes v with sorted object keys, no insignificant whitespace, // matching the subset of RFC 8785 we need for stable hashing. func canonicalJSON(v any) ([]byte, error) { return jsonMarshalSortedKeys(v) } // base64urlStd is an alias so tests can refer to the same encoding the real // signing service uses on the wire. var base64urlStd = base64.RawURLEncoding // EncodePayload returns the base64url-no-padding encoding of the canonical // statement bytes. Exposed for in-process test signers. func EncodePayload(stmt Statement) (string, []byte, error) { b, err := canonicalJSON(stmt) if err != nil { return "", nil, err } return base64urlStd.EncodeToString(b), b, nil } // EnvelopeContentHash returns the SHA-256 of the canonical JSON of env. // Used by both the Transparency Log server (to verify what the client sent) // and by in-process test signers (to produce a correct content_hash). func EnvelopeContentHash(env Envelope) (string, error) { b, err := canonicalJSON(env) if err != nil { return "", err } return "sha256:" + sha256hex(b), nil } // --- helpers moved to a separate file (hash.go) to keep this one readable --- // Compile-time assertion that the serialisation layer actually gets wired. var _ = json.Marshal