237 lines
8.1 KiB
Go
237 lines
8.1 KiB
Go
|
|
// 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,
|
||
|
|
}
|
||
|
|
}
|