ProofedMesh/services/approval-service/internal/service/orchestrator.go

237 lines
8.1 KiB
Go
Raw Normal View History

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
// Package service wires the approval pipeline components (sessions,
// content binding, webauthn, DSSE signing, Transparency Log) into one
// Confirm orchestrator. Keeping it HTTP-free lets unit tests drive the
// whole flow without an httptest server.
package service
import (
"context"
"errors"
"fmt"
"time"
"basicstack.de/proofedmesh/services/approval-service/internal/content"
"basicstack.de/proofedmesh/services/approval-service/internal/sessions"
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
"basicstack.de/proofedmesh/services/approval-service/internal/store"
"basicstack.de/proofedmesh/services/approval-service/internal/tlog"
"basicstack.de/proofedmesh/services/approval-service/internal/webauthn"
)
// Signer is the signing-service client the orchestrator depends on.
type Signer interface {
SignDSSE(ctx context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error)
}
// Submitter is the transparency-log client the orchestrator depends on.
type Submitter interface {
Submit(ctx context.Context, env signer.Envelope, contentHash string) (tlog.Receipt, error)
}
// Orchestrator holds everything a Confirm needs. Fields are populated
// from main.go; the HTTP handler just calls Confirm.
type Orchestrator struct {
Store store.Store
Sessions *sessions.Manager
Webauthn *webauthn.Store
WebauthnCfg webauthn.VerifyConfig
Signer Signer
Submitter Submitter
SigningKeyRef string
// AttestationIssuer is embedded as predicate.performed_by.iss so
// verifiers can match the approval against our Pocket-ID issuer.
AttestationIssuer string
Now func() time.Time
GenID sessions.IDGenerator
}
// ErrContentHashMismatch is surfaced by Confirm when the preview's
// stored canonical content no longer hashes to its stored content_hash
// (store corruption) OR when the webauthn challenge the browser signed
// does not match the challenge we expected for this preview. In both
// cases the HTTP layer returns 400 per DEV-608.
var ErrContentHashMismatch = errors.New("service: content hash mismatch")
// ErrChallengeExpired is surfaced by Confirm when the preview's
// expires_at is in the past. The HTTP layer returns 403.
var ErrChallengeExpired = errors.New("service: challenge expired")
// ConfirmResult is what Confirm returns to the HTTP handler.
type ConfirmResult struct {
ApprovalID string
AttestationRef string
LogIndex *int64
TLogStatus store.TLogStatus
}
// Confirm runs the full end-of-pipeline sequence:
//
// 1. Load the preview (must exist, must not be consumed, must still be
// within expires_at).
// 2. Re-derive the WebAuthn challenge from the stored (nonce,
// canonical) and verify the assertion against it.
// 3. Build the DSSE Statement with H_content as the subject digest.
// 4. Call the signing-service.
// 5. Persist the Approval row (status = attestation-pending).
// 6. Submit to the Transparency Log. On success, flip status to
// "logged" and attach log_index. On TLog failure, leave the row at
// attestation-pending so the reconciler can retry; the HTTP response
// still returns ApprovalID + attestation_ref so the UI can carry on.
//
// The order (persist → submit) is deliberate: we never return a
// log_index to the caller for an approval that was not durably stored,
// and we never lose a signed envelope because we failed to log it.
func (o *Orchestrator) Confirm(ctx context.Context, previewID string, assertion webauthn.Assertion, userSub string) (ConfirmResult, error) {
if previewID == "" {
return ConfirmResult{}, errors.New("service: previewID is empty")
}
if userSub == "" {
return ConfirmResult{}, errors.New("service: userSub is empty")
}
now := o.now()
pv, err := o.Store.GetPreview(ctx, previewID)
if err != nil {
return ConfirmResult{}, err
}
if pv.UserSub != userSub {
return ConfirmResult{}, store.ErrNotFound
}
if pv.ConsumedAt != nil {
return ConfirmResult{}, store.ErrAlreadyConsumed
}
if !now.Before(pv.ExpiresAt) {
return ConfirmResult{}, ErrChallengeExpired
}
// Defense-in-depth: recompute H_content from the stored canonical
// content and make sure it still equals the stored content_hash.
if content.RehashCanonical(pv.ContentCanonical) != pv.ContentHash {
return ConfirmResult{}, fmt.Errorf("%w: stored canonical content does not match stored content_hash", ErrContentHashMismatch)
}
expectedChallenge := content.ChallengeFor(pv.Nonce, pv.ContentCanonical)
cred, err := webauthn.Verify(o.WebauthnCfg, o.Webauthn, assertion, expectedChallenge)
if err != nil {
if errors.Is(err, webauthn.ErrChallengeMismatch) {
return ConfirmResult{}, fmt.Errorf("%w: %v", ErrContentHashMismatch, err)
}
return ConfirmResult{}, err
}
if cred.UserSub != userSub {
return ConfirmResult{}, webauthn.ErrAssertionInvalid
}
// Atomically mark the preview consumed so a parallel confirm cannot
// reuse the same signed envelope. If this fails ErrAlreadyConsumed
// we lose the race and must not continue.
if _, err := o.Store.ConsumePreview(ctx, pv.ID, now); err != nil {
return ConfirmResult{}, err
}
stmt := buildStatement(pv, userSub, o.AttestationIssuer, now)
env, envHash, err := o.Signer.SignDSSE(ctx, o.SigningKeyRef, stmt)
if err != nil {
return ConfirmResult{}, err
}
approvalID, err := o.genID("ap")
if err != nil {
return ConfirmResult{}, err
}
appr := store.Approval{
ID: approvalID,
PreviewID: pv.ID,
SessionID: pv.SessionID,
UserSub: userSub,
ContentHash: pv.ContentHash,
Envelope: env,
AttestationRef: envHash,
TLogStatus: store.TLogPending,
ConfirmedAt: now,
}
if err := o.Store.SaveApproval(ctx, appr); err != nil {
return ConfirmResult{}, err
}
receipt, submitErr := o.Submitter.Submit(ctx, env, envHash)
if submitErr != nil {
// Approval row stays at attestation-pending; record the error
// for diagnostics but return success-with-pending to the client.
_ = o.Store.MarkApprovalTLogError(ctx, approvalID, submitErr.Error())
return ConfirmResult{
ApprovalID: approvalID,
AttestationRef: envHash,
TLogStatus: store.TLogPending,
}, nil
}
loggedAt := receipt.LoggedAt
if loggedAt.IsZero() {
loggedAt = now
}
if err := o.Store.MarkApprovalLogged(ctx, approvalID, receipt.LogIndex, loggedAt); err != nil {
return ConfirmResult{}, err
}
idx := receipt.LogIndex
return ConfirmResult{
ApprovalID: approvalID,
AttestationRef: envHash,
LogIndex: &idx,
TLogStatus: store.TLogLogged,
}, nil
}
func (o *Orchestrator) now() time.Time {
if o.Now != nil {
return o.Now()
}
return time.Now()
}
// NewID returns an opaque ID with the given prefix. Exposed so the HTTP
// layer can mint preview_ids with the same injectable generator.
func (o *Orchestrator) NewID(prefix string) (string, error) {
if o.GenID != nil {
return o.GenID(prefix)
}
return sessions.DefaultIDGenerator(prefix)
}
func (o *Orchestrator) genID(prefix string) (string, error) { return o.NewID(prefix) }
// buildStatement renders the in-toto v1 Statement for an approval. The
// subject.digest IS the H_content of the preview; verifiers rehash the
// stored canonical content to prove the signature covers it.
func buildStatement(pv store.Preview, userSub, issuer string, performedAt time.Time) signer.Statement {
// Strip the "sha256:" prefix for the digest map, per in-toto convention.
digest := pv.ContentHash
if len(digest) > 7 && digest[:7] == "sha256:" {
digest = digest[7:]
}
predicate := map[string]any{
"approval_schema": "basicstack.de/attestations/approval/v1",
"preview_id": pv.ID,
"session_id": pv.SessionID,
"content_hash": pv.ContentHash,
"performed_by": map[string]any{
"iss": issuer,
"sub": userSub,
"session_id": pv.SessionID,
},
"performed_at": performedAt.UTC().Format(time.RFC3339),
"verification_digits": pv.VerificationDigits,
}
return signer.Statement{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{
map[string]any{
"name": "approval/" + pv.ID,
"digest": map[string]any{"sha256": digest},
},
},
"predicateType": "https://basicstack.de/attestations/approval/v1",
"predicate": predicate,
}
}