// 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, } }