ProofedMesh/services/approval-service/internal/content/binding.go
Paperclip 3a01cc651f approval-service: Phase-1 content-binding approval service (DEV-608)
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>
2026-10-25 19:42:06 +00:00

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)
}