ProofedMesh/services/approval-service/internal/sessions/sessions.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

97 lines
3 KiB
Go

// Package sessions implements the Session → Preview → Confirm pipeline on
// top of a Store. Keeping it one layer above the HTTP handlers lets the
// unit tests exercise business logic without an httptest server.
package sessions
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"basicstack.de/proofedmesh/services/approval-service/internal/store"
)
// IDGenerator returns opaque collision-resistant IDs. The default
// implementation uses crypto/rand; tests inject a deterministic one.
type IDGenerator func(prefix string) (string, error)
// DefaultIDGenerator produces "<prefix>_" + 24 random base64url chars.
func DefaultIDGenerator(prefix string) (string, error) {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("id: %w", err)
}
return prefix + "_" + base64.RawURLEncoding.EncodeToString(b), nil
}
// Manager orchestrates sessions on top of a Store. Its only state is the
// clock and ID generator; all persistence happens through Store.
type Manager struct {
Store store.Store
Now func() time.Time
GenID IDGenerator
SessionTTL time.Duration
ChallengeTTL time.Duration
}
// New constructs a Manager with safe defaults for Now, GenID, SessionTTL
// (15m) and ChallengeTTL (2m) when the caller leaves them zero.
func New(s store.Store) *Manager {
return &Manager{
Store: s,
Now: time.Now,
GenID: DefaultIDGenerator,
SessionTTL: 15 * time.Minute,
ChallengeTTL: 2 * time.Minute,
}
}
// StartSession creates a fresh approval_session for userSub.
func (m *Manager) StartSession(ctx context.Context, userSub string) (store.Session, error) {
if userSub == "" {
return store.Session{}, errors.New("sessions: userSub is empty")
}
id, err := m.GenID("sess")
if err != nil {
return store.Session{}, err
}
now := m.Now()
s := store.Session{
ID: id,
UserSub: userSub,
ActiveUntil: now.Add(m.SessionTTL),
CreatedAt: now,
}
if err := m.Store.CreateSession(ctx, s); err != nil {
return store.Session{}, err
}
return s, nil
}
// ErrSessionExpired is returned by GetActiveSession when the session
// row exists but active_until is in the past.
var ErrSessionExpired = errors.New("sessions: session expired")
// GetActiveSession returns the session when it is still within
// active_until. Enforces session_id ↔ user_sub binding (the caller passes
// the authenticated userSub so a leaked session_id cannot be used from
// another account).
func (m *Manager) GetActiveSession(ctx context.Context, sessionID, userSub string) (store.Session, error) {
s, err := m.Store.GetSession(ctx, sessionID)
if err != nil {
return store.Session{}, err
}
if s.UserSub != userSub {
// Explicit NotFound so handlers return 404 instead of leaking the
// mismatch — a probing attacker cannot distinguish "wrong user"
// from "unknown session".
return store.Session{}, store.ErrNotFound
}
if !m.Now().Before(s.ActiveUntil) {
return store.Session{}, ErrSessionExpired
}
return s, nil
}