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>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/auth"
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/httpapi"
|
|
"basicstack.de/proofedmesh/services/approval-service/internal/service"
|
|
"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"
|
|
pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi"
|
|
)
|
|
|
|
// fixture bundles everything a test needs to drive the HTTP server.
|
|
type fixture struct {
|
|
t *testing.T
|
|
server *httptest.Server
|
|
userSub string
|
|
credID string
|
|
credPriv ed25519.PrivateKey
|
|
rpID string
|
|
origin string
|
|
fakeSigner *fakeSigner
|
|
fakeTLog *fakeTLog
|
|
store *store.Memory
|
|
}
|
|
|
|
type fakeSigner struct {
|
|
mu sync.Mutex
|
|
err error
|
|
wantKeyRef string
|
|
seenStatement signer.Statement
|
|
}
|
|
|
|
func (f *fakeSigner) SignDSSE(_ context.Context, keyRef string, stmt signer.Statement) (signer.Envelope, string, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.err != nil {
|
|
return signer.Envelope{}, "", f.err
|
|
}
|
|
if f.wantKeyRef != "" && keyRef != f.wantKeyRef {
|
|
return signer.Envelope{}, "", errors.New("unexpected keyRef")
|
|
}
|
|
f.seenStatement = stmt
|
|
// Produce a deterministic envelope so tests can assert on it.
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte("payload-bytes"))
|
|
env := signer.Envelope{
|
|
PayloadType: "application/vnd.in-toto+json",
|
|
Payload: payload,
|
|
Signatures: []signer.EnvelopeSignature{{KeyID: keyRef, Sig: "AA"}},
|
|
}
|
|
envBytes, _ := json.Marshal(env)
|
|
sum := sha256.Sum256(envBytes)
|
|
return env, "sha256:" + hex(sum[:]), nil
|
|
}
|
|
|
|
type fakeTLog struct {
|
|
mu sync.Mutex
|
|
err error
|
|
submits int
|
|
lastHash string
|
|
}
|
|
|
|
func (f *fakeTLog) Submit(_ context.Context, _ signer.Envelope, contentHash string) (tlog.Receipt, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.submits++
|
|
f.lastHash = contentHash
|
|
if f.err != nil {
|
|
return tlog.Receipt{}, f.err
|
|
}
|
|
return tlog.Receipt{
|
|
LogIndex: 42,
|
|
ContentHash: contentHash,
|
|
LoggedAt: time.Now().UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// newFixture wires a Server backed by an in-memory store + fakes. A single
|
|
// WebAuthn credential is pre-registered for userSub so tests can confirm
|
|
// previews without going through a real enrollment ceremony.
|
|
func newFixture(t *testing.T, opts ...func(*service.Orchestrator)) *fixture {
|
|
t.Helper()
|
|
st := store.NewMemory()
|
|
mgr := sessions.New(st)
|
|
mgr.SessionTTL = 15 * time.Minute
|
|
mgr.ChallengeTTL = 2 * time.Minute
|
|
|
|
userSub := "user-alice"
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen key: %v", err)
|
|
}
|
|
idBytes := make([]byte, 16)
|
|
_, _ = rand.Read(idBytes)
|
|
credID := base64.RawURLEncoding.EncodeToString(idBytes)
|
|
webStore := webauthn.NewStore()
|
|
if err := webStore.AddCredential(webauthn.Credential{
|
|
ID: credID,
|
|
UserSub: userSub,
|
|
PublicKey: pub,
|
|
}); err != nil {
|
|
t.Fatalf("register credential: %v", err)
|
|
}
|
|
|
|
rpID := "trust.basicstack.de"
|
|
origin := "https://trust.basicstack.de"
|
|
|
|
sig := &fakeSigner{wantKeyRef: "trust-api/approval-signer-2026-q4"}
|
|
submitter := &fakeTLog{}
|
|
|
|
orch := &service.Orchestrator{
|
|
Store: st,
|
|
Sessions: mgr,
|
|
Webauthn: webStore,
|
|
WebauthnCfg: webauthn.VerifyConfig{
|
|
RPID: rpID,
|
|
AllowedOrigins: []string{origin},
|
|
},
|
|
Signer: sig,
|
|
Submitter: submitter,
|
|
SigningKeyRef: "trust-api/approval-signer-2026-q4",
|
|
AttestationIssuer: "https://auth.basicstack.de",
|
|
}
|
|
for _, o := range opts {
|
|
o(orch)
|
|
}
|
|
|
|
srv := &httpapi.Server{
|
|
Auth: httpapi.AuthFunc(func(_ *http.Request) (auth.Principal, error) {
|
|
return auth.Principal{Sub: userSub, Groups: []string{"proofedmesh-admin"}}, nil
|
|
}),
|
|
Sessions: mgr,
|
|
Orchestrator: orch,
|
|
}
|
|
mux := http.NewServeMux()
|
|
srv.Mount(mux)
|
|
ts := httptest.NewServer(mux)
|
|
t.Cleanup(ts.Close)
|
|
|
|
return &fixture{
|
|
t: t,
|
|
server: ts,
|
|
userSub: userSub,
|
|
credID: credID,
|
|
credPriv: priv,
|
|
rpID: rpID,
|
|
origin: origin,
|
|
fakeSigner: sig,
|
|
fakeTLog: submitter,
|
|
store: st,
|
|
}
|
|
}
|
|
|
|
func (f *fixture) startSession() pkgapi.CreateSessionResponse {
|
|
f.t.Helper()
|
|
var out pkgapi.CreateSessionResponse
|
|
f.do("POST", "/api/v1/sessions", nil, http.StatusCreated, &out)
|
|
return out
|
|
}
|
|
|
|
func (f *fixture) preview(sessionID string, content map[string]any, want int) pkgapi.PreviewResponse {
|
|
f.t.Helper()
|
|
var out pkgapi.PreviewResponse
|
|
f.do("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
|
|
SessionID: sessionID,
|
|
Content: content,
|
|
}, want, &out)
|
|
return out
|
|
}
|
|
|
|
func (f *fixture) confirm(req pkgapi.ConfirmRequest, want int) (*pkgapi.ConfirmResponse, *pkgapi.Error) {
|
|
f.t.Helper()
|
|
resp := f.doRaw("POST", "/api/v1/approvals/confirm", req)
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != want {
|
|
f.t.Fatalf("confirm: status %d want %d: %s", resp.StatusCode, want, string(body))
|
|
}
|
|
if want >= 200 && want < 300 {
|
|
var out pkgapi.ConfirmResponse
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
f.t.Fatalf("decode confirm: %v (%s)", err, string(body))
|
|
}
|
|
return &out, nil
|
|
}
|
|
var ebody pkgapi.Error
|
|
if err := json.Unmarshal(body, &ebody); err != nil {
|
|
f.t.Fatalf("decode confirm error: %v (%s)", err, string(body))
|
|
}
|
|
return nil, &ebody
|
|
}
|
|
|
|
func (f *fixture) do(method, path string, body any, want int, out any) {
|
|
f.t.Helper()
|
|
resp := f.doRaw(method, path, body)
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != want {
|
|
f.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, string(raw))
|
|
}
|
|
if out != nil {
|
|
if err := json.Unmarshal(raw, out); err != nil {
|
|
f.t.Fatalf("decode %s: %v (%s)", path, err, string(raw))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *fixture) doRaw(method, path string, body any) *http.Response {
|
|
f.t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
f.t.Fatalf("marshal body: %v", err)
|
|
}
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, f.server.URL+path, r)
|
|
if err != nil {
|
|
f.t.Fatalf("new request: %v", err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := f.server.Client().Do(req)
|
|
if err != nil {
|
|
f.t.Fatalf("do: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (f *fixture) signAssertion(challenge []byte) pkgapi.Assertion {
|
|
f.t.Helper()
|
|
cd := map[string]string{
|
|
"type": "webauthn.get",
|
|
"challenge": base64.RawURLEncoding.EncodeToString(challenge),
|
|
"origin": f.origin,
|
|
}
|
|
cdBytes, _ := json.Marshal(cd)
|
|
authData := webauthn.MakeAuthenticatorData(f.rpID)
|
|
cdHash := sha256.Sum256(cdBytes)
|
|
signed := append(append([]byte(nil), authData...), cdHash[:]...)
|
|
sig := ed25519.Sign(f.credPriv, signed)
|
|
return pkgapi.Assertion{
|
|
CredentialID: f.credID,
|
|
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cdBytes),
|
|
AuthenticatorData: base64.RawURLEncoding.EncodeToString(authData),
|
|
Signature: base64.RawURLEncoding.EncodeToString(sig),
|
|
}
|
|
}
|
|
|
|
func hex(b []byte) string {
|
|
const hexchars = "0123456789abcdef"
|
|
out := make([]byte, len(b)*2)
|
|
for i, c := range b {
|
|
out[i*2] = hexchars[c>>4]
|
|
out[i*2+1] = hexchars[c&0x0f]
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// --- actual tests ---------------------------------------------------------
|
|
|
|
func TestHappyPath(t *testing.T) {
|
|
f := newFixture(t)
|
|
sess := f.startSession()
|
|
if sess.SessionID == "" {
|
|
t.Fatalf("no session_id returned")
|
|
}
|
|
if sess.UserSub != f.userSub {
|
|
t.Errorf("session.user_sub = %q want %q", sess.UserSub, f.userSub)
|
|
}
|
|
|
|
pv := f.preview(sess.SessionID, map[string]any{
|
|
"title": "Approve rate change",
|
|
"action": "sign",
|
|
}, http.StatusOK)
|
|
if !strings.HasPrefix(pv.ContentHash, "sha256:") {
|
|
t.Errorf("preview content_hash: %q", pv.ContentHash)
|
|
}
|
|
if pv.VerificationDigits == "" {
|
|
t.Errorf("empty verification digits")
|
|
}
|
|
|
|
challenge, err := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
if err != nil {
|
|
t.Fatalf("decode challenge: %v", err)
|
|
}
|
|
assertion := f.signAssertion(challenge)
|
|
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
|
if ok.TLogStatus != "logged" {
|
|
t.Errorf("tlog status: %q want logged", ok.TLogStatus)
|
|
}
|
|
if ok.LogIndex == nil || *ok.LogIndex != 42 {
|
|
t.Errorf("log_index = %v want 42", ok.LogIndex)
|
|
}
|
|
if ok.AttestationRef == "" || !strings.HasPrefix(ok.AttestationRef, "sha256:") {
|
|
t.Errorf("attestation_ref: %q", ok.AttestationRef)
|
|
}
|
|
|
|
// Assert the signer saw a Statement whose subject.digest.sha256 is
|
|
// the H_content of the preview (no "sha256:" prefix per in-toto).
|
|
f.fakeSigner.mu.Lock()
|
|
stmt := f.fakeSigner.seenStatement
|
|
f.fakeSigner.mu.Unlock()
|
|
subj, _ := stmt["subject"].([]any)
|
|
if len(subj) != 1 {
|
|
t.Fatalf("statement subject: %+v", subj)
|
|
}
|
|
first, _ := subj[0].(map[string]any)
|
|
digest, _ := first["digest"].(map[string]any)
|
|
gotDigest, _ := digest["sha256"].(string)
|
|
wantDigest := strings.TrimPrefix(pv.ContentHash, "sha256:")
|
|
if gotDigest != wantDigest {
|
|
t.Errorf("signed subject digest=%q want %q", gotDigest, wantDigest)
|
|
}
|
|
}
|
|
|
|
func TestConfirm_TamperedHContent(t *testing.T) {
|
|
f := newFixture(t)
|
|
sess := f.startSession()
|
|
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
|
|
|
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
// Flip the last byte of the challenge (equivalent to signing with a
|
|
// different H_content), the signature will be over wrong data.
|
|
tampered := append([]byte(nil), challenge...)
|
|
tampered[len(tampered)-1] ^= 0xff
|
|
assertion := f.signAssertion(tampered)
|
|
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadRequest)
|
|
if errBody.Error.Code != "content_hash_mismatch" {
|
|
t.Errorf("error code = %q want content_hash_mismatch", errBody.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfirm_ExpiredChallenge(t *testing.T) {
|
|
// Force a short challenge TTL so we can race the clock.
|
|
f := newFixture(t)
|
|
sess := f.startSession()
|
|
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
|
|
|
// Walk the preview's ExpiresAt backward directly in the store to
|
|
// simulate an expired challenge without a sleep.
|
|
prev, err := f.store.GetPreview(context.Background(), pv.PreviewID)
|
|
if err != nil {
|
|
t.Fatalf("GetPreview: %v", err)
|
|
}
|
|
prev.ExpiresAt = time.Now().Add(-1 * time.Second)
|
|
if err := f.store.SavePreview(context.Background(), prev); err != nil {
|
|
t.Fatalf("SavePreview: %v", err)
|
|
}
|
|
|
|
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
assertion := f.signAssertion(challenge)
|
|
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusForbidden)
|
|
if errBody.Error.Code != "challenge_expired" {
|
|
t.Errorf("error code = %q want challenge_expired", errBody.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfirm_SignerUnavailable(t *testing.T) {
|
|
f := newFixture(t, func(o *service.Orchestrator) {
|
|
o.Signer = &fakeSigner{err: signer.ErrSignerUnavailable}
|
|
})
|
|
sess := f.startSession()
|
|
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
|
|
|
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
assertion := f.signAssertion(challenge)
|
|
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusBadGateway)
|
|
if errBody.Error.Code != "signer_unavailable" {
|
|
t.Errorf("error code = %q want signer_unavailable", errBody.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfirm_TLogFailureStaysPending(t *testing.T) {
|
|
f := newFixture(t, func(o *service.Orchestrator) {
|
|
o.Submitter = &fakeTLog{err: tlog.ErrTLogUnavailable}
|
|
})
|
|
sess := f.startSession()
|
|
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
|
|
|
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
assertion := f.signAssertion(challenge)
|
|
ok, _ := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
|
if ok.TLogStatus != "attestation-pending" {
|
|
t.Errorf("tlog status = %q want attestation-pending", ok.TLogStatus)
|
|
}
|
|
if ok.LogIndex != nil {
|
|
t.Errorf("log_index = %v want nil", ok.LogIndex)
|
|
}
|
|
// Approval must still have been persisted.
|
|
appr, ok2 := f.store.GetApproval(ok.ApprovalID)
|
|
if !ok2 {
|
|
t.Fatalf("approval not persisted after tlog failure")
|
|
}
|
|
if appr.TLogStatus != store.TLogPending {
|
|
t.Errorf("stored tlog status = %q want attestation-pending", appr.TLogStatus)
|
|
}
|
|
if appr.TLogError == "" {
|
|
t.Errorf("expected tlog_error to be recorded")
|
|
}
|
|
}
|
|
|
|
func TestPreview_SessionExpired(t *testing.T) {
|
|
f := newFixture(t)
|
|
sess := f.startSession()
|
|
// Simulate session expiry.
|
|
raw, err := f.store.GetSession(context.Background(), sess.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
raw.ActiveUntil = time.Now().Add(-1 * time.Second)
|
|
if err := f.store.CreateSession(context.Background(), raw); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
resp := f.doRaw("POST", "/api/v1/approvals/preview", pkgapi.PreviewRequest{
|
|
SessionID: sess.SessionID,
|
|
Content: map[string]any{"k": "v"},
|
|
})
|
|
defer resp.Body.Close()
|
|
raw2, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("status %d want 403: %s", resp.StatusCode, raw2)
|
|
}
|
|
var ebody pkgapi.Error
|
|
_ = json.Unmarshal(raw2, &ebody)
|
|
if ebody.Error.Code != "session_expired" {
|
|
t.Errorf("error code = %q want session_expired", ebody.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestConfirm_PreviewConsumedTwice(t *testing.T) {
|
|
f := newFixture(t)
|
|
sess := f.startSession()
|
|
pv := f.preview(sess.SessionID, map[string]any{"k": "v"}, http.StatusOK)
|
|
challenge, _ := base64.RawURLEncoding.DecodeString(pv.WebauthnChallenge)
|
|
assertion := f.signAssertion(challenge)
|
|
_, _ = f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusOK)
|
|
// Second confirm must be refused.
|
|
_, errBody := f.confirm(pkgapi.ConfirmRequest{PreviewID: pv.PreviewID, Assertion: assertion}, http.StatusConflict)
|
|
if errBody.Error.Code != "preview_consumed" {
|
|
t.Errorf("error code = %q want preview_consumed", errBody.Error.Code)
|
|
}
|
|
}
|