239 lines
7.5 KiB
Go
239 lines
7.5 KiB
Go
|
|
// Package attest mirrors the admin-api attestation API (ADR-0005). Current
|
||
|
|
// state: the reconciler re-declares the small Signer/Submitter contract so it
|
||
|
|
// can live in its own go.mod without pulling admin-api's internal/ tree.
|
||
|
|
// When a third consumer appears (per the parent issue's note), these types
|
||
|
|
// move into a shared module and both admin-api and attest-reconciler switch
|
||
|
|
// their imports; the wire types do not change.
|
||
|
|
package attest
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Statement is the in-toto v1 Statement shape handed to the Signing Service.
|
||
|
|
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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Signer wraps POST /api/v1/sign/dsse.
|
||
|
|
type Signer interface {
|
||
|
|
SignDSSE(ctx context.Context, keyRef string, stmt Statement) (envelope Envelope, contentHash string, err error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Submitter wraps POST /api/v1/log/entries. MUST be idempotent on
|
||
|
|
// (envelope, contentHash); that is the whole reason the reconciler can safely
|
||
|
|
// re-run already-attested rows without producing duplicates.
|
||
|
|
type Submitter interface {
|
||
|
|
Submit(ctx context.Context, env Envelope, contentHash string) (LogReceipt, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type TokenSource interface {
|
||
|
|
Token(ctx context.Context) (string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type StaticToken string
|
||
|
|
|
||
|
|
func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil }
|
||
|
|
|
||
|
|
// ChangeKind labels why a rate changed.
|
||
|
|
type ChangeKind string
|
||
|
|
|
||
|
|
const (
|
||
|
|
ChangeCreate ChangeKind = "create"
|
||
|
|
ChangeRetire ChangeKind = "retire"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Rate is the subset of per_diem_rates the reconciler needs to re-sign a row.
|
||
|
|
// Field layout mirrors admin-api/internal/model.Rate so JSON round-trips with
|
||
|
|
// the same canonical bytes.
|
||
|
|
type Rate struct {
|
||
|
|
RateID string `json:"rate_id"`
|
||
|
|
Country string `json:"country"`
|
||
|
|
City *string `json:"city,omitempty"`
|
||
|
|
Currency string `json:"currency"`
|
||
|
|
FullDay string `json:"full_day"`
|
||
|
|
PartialDayMin8h string `json:"partial_day_min8h"`
|
||
|
|
ArrivalDay string `json:"arrival_day"`
|
||
|
|
DepartureDay string `json:"departure_day"`
|
||
|
|
ValidFrom time.Time `json:"valid_from"`
|
||
|
|
ValidTo *time.Time `json:"valid_to,omitempty"`
|
||
|
|
SourceRegulation string `json:"source_regulation"`
|
||
|
|
CreatedBy string `json:"created_by"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// RateChangeEvent is what the reconciler reconstructs for a pending row.
|
||
|
|
type RateChangeEvent struct {
|
||
|
|
Kind ChangeKind
|
||
|
|
Rate Rate
|
||
|
|
PerformedBy PerformedBy
|
||
|
|
PerformedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type PerformedBy struct {
|
||
|
|
Issuer string `json:"iss"`
|
||
|
|
Subject string `json:"sub"`
|
||
|
|
SessionID string `json:"session_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Orchestrator signs a rate_change event, submits it to the TLog, and returns
|
||
|
|
// the attestation content_hash. Semantics match admin-api exactly: the TLog
|
||
|
|
// side is idempotent, so re-running an already-signed event is safe and
|
||
|
|
// returns the same content_hash.
|
||
|
|
type Orchestrator struct {
|
||
|
|
Signer Signer
|
||
|
|
Submitter Submitter
|
||
|
|
KeyRef string
|
||
|
|
}
|
||
|
|
|
||
|
|
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 the canonical in-toto v1 Statement for a rate change.
|
||
|
|
// Byte-identical to admin-api/internal/attest.buildStatement so the Signing
|
||
|
|
// Service receives the same canonical bytes for the same row and the TLog
|
||
|
|
// idempotency key matches on both sides.
|
||
|
|
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")
|
||
|
|
}
|
||
|
|
|
||
|
|
func hashRateCore(r 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
|
||
|
|
}
|
||
|
|
|
||
|
|
var base64urlStd = base64.RawURLEncoding
|
||
|
|
|
||
|
|
// EncodePayload is kept for parity with admin-api; the reconciler uses it only
|
||
|
|
// in tests that construct an envelope directly.
|
||
|
|
func EncodePayload(stmt Statement) (string, []byte, error) {
|
||
|
|
b, err := canonicalJSON(stmt)
|
||
|
|
if err != nil {
|
||
|
|
return "", nil, err
|
||
|
|
}
|
||
|
|
return base64urlStd.EncodeToString(b), b, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func EnvelopeContentHash(env Envelope) (string, error) {
|
||
|
|
b, err := canonicalJSON(env)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return "sha256:" + sha256hex(b), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
var _ = json.Marshal
|