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>
90 lines
3 KiB
Go
90 lines
3 KiB
Go
// Package store defines the persistence contract for approval_sessions,
|
|
// approval_previews, and approvals. Phase 1 ships an in-memory impl; the
|
|
// Postgres schema is in db/trust/migrations/0004_approval_sessions.*.sql
|
|
// and wired via a skeleton Postgres type (see postgres.go) so the HTTP
|
|
// layer stays unchanged when the real pgxpool wiring lands.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
|
|
)
|
|
|
|
// ErrNotFound is returned by lookup methods when the row does not exist
|
|
// or has expired.
|
|
var ErrNotFound = errors.New("store: not found")
|
|
|
|
// ErrAlreadyConsumed is returned by ConsumePreview when the preview has
|
|
// already been consumed — the confirm endpoint MUST reject a replay.
|
|
var ErrAlreadyConsumed = errors.New("store: preview already consumed")
|
|
|
|
// Session is a per-browser-login approval session. active_until bounds
|
|
// how long previews may be created against it.
|
|
type Session struct {
|
|
ID string
|
|
UserSub string
|
|
ActiveUntil time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Preview is a server-rendered approval payload that has not yet been
|
|
// confirmed. ContentCanonical lets the confirm endpoint recompute
|
|
// H_content deterministically from the stored row.
|
|
type Preview struct {
|
|
ID string
|
|
SessionID string
|
|
UserSub string
|
|
ContentHash string
|
|
ContentCanonical []byte
|
|
Salt []byte
|
|
Nonce []byte
|
|
VerificationDigits string
|
|
ExpiresAt time.Time
|
|
CreatedAt time.Time
|
|
ConsumedAt *time.Time
|
|
}
|
|
|
|
// TLogStatus is the three-valued state of the Transparency-Log submit.
|
|
type TLogStatus string
|
|
|
|
const (
|
|
TLogPending TLogStatus = "attestation-pending"
|
|
TLogLogged TLogStatus = "logged"
|
|
TLogFailed TLogStatus = "failed"
|
|
)
|
|
|
|
// Approval is the confirmed, DSSE-signed approval record. If TLogStatus
|
|
// is TLogPending/TLogFailed, log_index is nil and the attest-reconciler
|
|
// (or a future worker) will replay the Transparency-Log submit against
|
|
// the server's idempotent /api/v1/log/entries contract.
|
|
type Approval struct {
|
|
ID string
|
|
PreviewID string
|
|
SessionID string
|
|
UserSub string
|
|
ContentHash string
|
|
Envelope signer.Envelope
|
|
AttestationRef string
|
|
TLogStatus TLogStatus
|
|
LogIndex *int64
|
|
TLogError string
|
|
ConfirmedAt time.Time
|
|
LoggedAt *time.Time
|
|
}
|
|
|
|
// Store is the full persistence surface the HTTP service depends on.
|
|
type Store interface {
|
|
CreateSession(ctx context.Context, s Session) error
|
|
GetSession(ctx context.Context, id string) (Session, error)
|
|
|
|
SavePreview(ctx context.Context, p Preview) error
|
|
GetPreview(ctx context.Context, id string) (Preview, error)
|
|
ConsumePreview(ctx context.Context, id string, now time.Time) (Preview, error)
|
|
|
|
SaveApproval(ctx context.Context, a Approval) error
|
|
MarkApprovalLogged(ctx context.Context, id string, logIndex int64, loggedAt time.Time) error
|
|
MarkApprovalTLogError(ctx context.Context, id string, errorMsg string) error
|
|
}
|