New services/approval-service with the Session → Preview → Confirm
pipeline required by DEV-606/DEV-608:
* POST /api/v1/sessions starts a Pocket-ID-bound approval session.
* POST /api/v1/approvals/preview renders content server-side, computes
H_content = sha256(canonical JSON), stores the (nonce, salt, canonical,
challenge) tuple and returns the human verification digits plus the
WebAuthn challenge = nonce || H_content.
* POST /api/v1/approvals/confirm verifies the WebAuthn assertion against
the stored challenge, DSSE-signs the Approval statement via the
signing-service (key_ref=trust-api/approval-signer-2026-q4), submits
to the transparency-log, and returns {approval_id, attestation_ref,
log_index}. TLog failures leave the row at attestation-pending for
the reconciler.
* db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables
approval_sessions, approval_previews, approvals (schema matches the
Phase 1 store contract).
* internal/content: canonical JSON + H_content + 6-digit verification
code (byte-compatible with signing-service/internal/canonical).
* internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays
WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn.
* internal/signer, internal/tlog: HTTP clients mapping 5xx / connection
errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the
HTTP layer can return 502 for signing failures and persist
attestation-pending on TLog failure.
* internal/service.Orchestrator ties the pipeline together and persists
the Approval BEFORE the TLog submit (never return a log_index for
an un-persisted approval).
Tests cover: happy path, tampered H_content → 400, expired challenge
→ 403, signer 5xx + conn-refused → 502, TLog failure → row stays
attestation-pending, duplicate confirm → 409, session expired → 403.
go vet clean, go test ./... passes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
117 lines
4.4 KiB
Go
117 lines
4.4 KiB
Go
// Package content implements the content-binding primitives of the
|
|
// approval-service: the H_content hash of a renderable approval payload,
|
|
// the WebAuthn challenge derivation (nonce || H_content), and the human-
|
|
// readable verification digits an operator cross-checks against the UI.
|
|
//
|
|
// The *content* passed in from the HTTP handler is server-rendered JSON —
|
|
// not the raw request payload. "Server-rendered" means we have already
|
|
// resolved template variables, lookups, and defaults, so H_content is a
|
|
// hash of exactly what the approving human saw. See
|
|
// docs/architecture/v1.1.md §4.3.
|
|
package content
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/canonical"
|
|
)
|
|
|
|
// Phase1Rendered is the server-rendered approval payload. For Phase 1 the
|
|
// approval-service does not own a template language — the caller hands us
|
|
// the fully resolved object (title, bullets, action, etc.) and we hash it
|
|
// as-is. Phase 2 will introduce typed template kinds.
|
|
type Phase1Rendered = map[string]any
|
|
|
|
// Binding is everything the preview endpoint persists so the confirm
|
|
// endpoint can verify the WebAuthn assertion unambiguously.
|
|
type Binding struct {
|
|
// ContentCanonical is the canonical JSON bytes of the rendered content.
|
|
ContentCanonical []byte
|
|
// ContentHash is "sha256:<hex>" over ContentCanonical.
|
|
ContentHash string
|
|
// Salt is 16 random bytes mixed into VerificationDigits so an attacker
|
|
// who guesses a content payload cannot pre-compute its digits.
|
|
Salt []byte
|
|
// Nonce is 32 random bytes forming the first half of the WebAuthn
|
|
// challenge; the second half is the raw sha256 of ContentCanonical.
|
|
Nonce []byte
|
|
// Challenge is Nonce || sha256(ContentCanonical). Base64url-encoded on
|
|
// the wire inside the WebAuthn ClientDataJSON `challenge` field.
|
|
Challenge []byte
|
|
// VerificationDigits is a short 6-digit number derived from the
|
|
// H_content + Salt, shown to the human for cross-checking.
|
|
VerificationDigits string
|
|
}
|
|
|
|
// Bind computes the full content-binding for rendered. The caller MUST
|
|
// pass server-rendered content (NOT raw user input).
|
|
func Bind(rendered Phase1Rendered) (Binding, error) {
|
|
if rendered == nil {
|
|
return Binding{}, errors.New("content: rendered payload is nil")
|
|
}
|
|
raw, err := canonical.Marshal(rendered)
|
|
if err != nil {
|
|
return Binding{}, fmt.Errorf("canonicalise rendered: %w", err)
|
|
}
|
|
sum := sha256.Sum256(raw)
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return Binding{}, fmt.Errorf("salt: %w", err)
|
|
}
|
|
nonce := make([]byte, 32)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return Binding{}, fmt.Errorf("nonce: %w", err)
|
|
}
|
|
challenge := make([]byte, 0, len(nonce)+len(sum))
|
|
challenge = append(challenge, nonce...)
|
|
challenge = append(challenge, sum[:]...)
|
|
return Binding{
|
|
ContentCanonical: raw,
|
|
ContentHash: "sha256:" + hex.EncodeToString(sum[:]),
|
|
Salt: salt,
|
|
Nonce: nonce,
|
|
Challenge: challenge,
|
|
VerificationDigits: digits(sum[:], salt),
|
|
}, nil
|
|
}
|
|
|
|
// RehashCanonical takes previously-stored canonical bytes and recomputes
|
|
// the hash. Used on the confirm path to prove the preview's canonical
|
|
// bytes still produce the expected ContentHash — defense-in-depth against
|
|
// store tampering.
|
|
func RehashCanonical(canonicalJSON []byte) string {
|
|
sum := sha256.Sum256(canonicalJSON)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// ChallengeFor returns the canonical WebAuthn challenge bytes given a
|
|
// stored (nonce, canonicalJSON) pair. We re-derive the challenge rather
|
|
// than storing it so a corrupted challenge column cannot smuggle an
|
|
// attacker-chosen value into the signature path.
|
|
func ChallengeFor(nonce, canonicalJSON []byte) []byte {
|
|
sum := sha256.Sum256(canonicalJSON)
|
|
out := make([]byte, 0, len(nonce)+len(sum))
|
|
out = append(out, nonce...)
|
|
out = append(out, sum[:]...)
|
|
return out
|
|
}
|
|
|
|
// digits folds H_content and salt into a 6-digit decimal code. The salt
|
|
// prevents an attacker who knows a candidate rendered payload from pre-
|
|
// computing the digits an operator will see.
|
|
func digits(hContent, salt []byte) string {
|
|
h := sha256.New()
|
|
h.Write([]byte("verification-digits/v1\x00"))
|
|
h.Write(hContent)
|
|
h.Write([]byte{0x00})
|
|
h.Write(salt)
|
|
sum := h.Sum(nil)
|
|
// take the first 8 bytes as a big-endian uint64, mod 10^6 for 6 digits.
|
|
n := binary.BigEndian.Uint64(sum[:8]) % 1_000_000
|
|
return fmt.Sprintf("%06d", n)
|
|
}
|