237 lines
8.1 KiB
Go
237 lines
8.1 KiB
Go
// 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
|