ProofedMesh/services/admin-api/internal/attest/ratechange.go

238 lines
8.1 KiB
Go
Raw Normal View History

// 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:<hex>) 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