diff --git a/Cargo.lock b/Cargo.lock index 8fa1e79..2ecfb3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1189,6 +1189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2133,6 +2134,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tokio", "toml 0.8.23", "tracing", "ureq 2.12.1", @@ -2416,6 +2418,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "chacha20poly1305", "chrono", "ed25519-dalek", "figment", @@ -2455,6 +2458,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e4f48c5..53ccb85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ fs2 = "0.4.3" sha2 = "0.10" hmac = "0.12" argon2 = "0.5.3" +chacha20poly1305 = "0.10.1" keyring = "4.1.5" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/down.sql b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/down.sql new file mode 100644 index 0000000..5de96a9 --- /dev/null +++ b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/down.sql @@ -0,0 +1,6 @@ +-- Remove encrypted recovery delivery state before its digest-only token source. + +DROP + TABLE account_password_recovery_outbox; +DROP + TABLE account_password_recovery_tokens; diff --git a/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/up.sql b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/up.sql new file mode 100644 index 0000000..3211bbb --- /dev/null +++ b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000000_add_account_password_recovery/up.sql @@ -0,0 +1,129 @@ +-- Add digest-only reset tokens and an encrypted, lease-driven delivery outbox. + +CREATE TABLE account_password_recovery_tokens ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES account_password_credentials(account_id) ON DELETE CASCADE, + token_digest BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + CONSTRAINT account_password_recovery_token_digest_length CHECK ( + octet_length(token_digest) = 32 + ), + CONSTRAINT account_password_recovery_token_expiry_order CHECK ( + expires_at > created_at + ), + CONSTRAINT account_password_recovery_token_consumption_order CHECK ( + consumed_at IS NULL OR consumed_at >= created_at + ), + CONSTRAINT account_password_recovery_token_revocation_order CHECK ( + revoked_at IS NULL OR revoked_at >= created_at + ), + CONSTRAINT account_password_recovery_token_terminal_exclusive CHECK ( + consumed_at IS NULL OR revoked_at IS NULL + ) +); + +CREATE UNIQUE INDEX account_password_recovery_tokens_one_active_account_idx + ON account_password_recovery_tokens (account_id) + WHERE consumed_at IS NULL AND revoked_at IS NULL; + +CREATE INDEX account_password_recovery_tokens_active_digest_idx + ON account_password_recovery_tokens (token_digest, expires_at) + WHERE consumed_at IS NULL AND revoked_at IS NULL; + +CREATE INDEX account_password_recovery_tokens_account_created_idx + ON account_password_recovery_tokens (account_id, created_at DESC); + +CREATE TABLE account_password_recovery_outbox ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES account_password_credentials(account_id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('reset', 'password_changed')), + recipient TEXT NOT NULL, + ciphertext BYTEA NOT NULL, + nonce BYTEA NOT NULL, + key_version SMALLINT NOT NULL CHECK (key_version > 0), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK ( + attempt_count BETWEEN 0 AND 1000 + ), + last_attempt_at TIMESTAMPTZ, + claim_id UUID, + claimed_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + sent_at TIMESTAMPTZ, + provider_message_id TEXT, + failed_at TIMESTAMPTZ, + last_error_code TEXT, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT account_password_recovery_outbox_recipient_normalized CHECK ( + recipient = lower(btrim(recipient)) + AND recipient LIKE '%@%' + AND char_length(recipient) BETWEEN 3 AND 320 + ), + CONSTRAINT account_password_recovery_outbox_ciphertext_length CHECK ( + octet_length(ciphertext) BETWEEN 16 AND 262144 + ), + CONSTRAINT account_password_recovery_outbox_nonce_length CHECK ( + octet_length(nonce) = 24 + ), + CONSTRAINT account_password_recovery_outbox_expiry_order CHECK ( + next_attempt_at >= created_at + AND next_attempt_at < expires_at + ), + CONSTRAINT account_password_recovery_outbox_attempt_shape CHECK ( + (attempt_count = 0 AND last_attempt_at IS NULL) + OR (attempt_count > 0 AND last_attempt_at IS NOT NULL) + ), + CONSTRAINT account_password_recovery_outbox_claim_shape CHECK ( + (claim_id IS NULL AND claimed_at IS NULL) + OR ( + claim_id IS NOT NULL + AND claimed_at IS NOT NULL + AND claimed_at = last_attempt_at + AND claimed_at < expires_at + ) + ), + CONSTRAINT account_password_recovery_outbox_terminal_exclusive CHECK ( + sent_at IS NULL OR failed_at IS NULL + ), + CONSTRAINT account_password_recovery_outbox_terminal_claim_released CHECK ( + (sent_at IS NULL AND failed_at IS NULL) + OR (claim_id IS NULL AND claimed_at IS NULL) + ), + CONSTRAINT account_password_recovery_outbox_provider_id_shape CHECK ( + (sent_at IS NULL AND provider_message_id IS NULL) + OR ( + sent_at IS NOT NULL + AND provider_message_id IS NOT NULL + AND provider_message_id = btrim(provider_message_id) + AND octet_length(provider_message_id) BETWEEN 1 AND 256 + AND provider_message_id !~ '[[:cntrl:]]' + ) + ), + CONSTRAINT account_password_recovery_outbox_error_code_shape CHECK ( + last_error_code IS NULL + OR last_error_code ~ '^[a-z0-9][a-z0-9_.:-]{0,63}$' + ), + CONSTRAINT account_password_recovery_outbox_failure_has_code CHECK ( + failed_at IS NULL OR last_error_code IS NOT NULL + ), + CONSTRAINT account_password_recovery_outbox_timestamp_order CHECK ( + expires_at > created_at + AND (last_attempt_at IS NULL OR last_attempt_at >= created_at) + AND (sent_at IS NULL OR sent_at >= created_at) + AND (failed_at IS NULL OR failed_at >= created_at) + ) +); + +CREATE INDEX account_password_recovery_outbox_ready_idx + ON account_password_recovery_outbox (next_attempt_at, expires_at, created_at, id) + WHERE sent_at IS NULL AND failed_at IS NULL; + +CREATE INDEX account_password_recovery_outbox_stale_claim_idx + ON account_password_recovery_outbox (claimed_at, next_attempt_at, id) + WHERE sent_at IS NULL AND failed_at IS NULL AND claim_id IS NOT NULL; + +CREATE INDEX account_password_recovery_outbox_account_created_idx + ON account_password_recovery_outbox (account_id, created_at DESC); diff --git a/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/down.sql b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/down.sql new file mode 100644 index 0000000..d184735 --- /dev/null +++ b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/down.sql @@ -0,0 +1,10 @@ +-- Authentication credentials, replay evidence, MFA state, authorization +-- codes, and audit rows are security records. Destructive rollback would +-- silently invalidate incident evidence and live credentials, so it is +-- intentionally refused. +DO $$ +BEGIN + RAISE EXCEPTION + 'account authentication hardening migration is forward-only; preserve credential, replay, MFA, authorization-code, and audit records'; +END +$$; diff --git a/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/up.sql b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/up.sql new file mode 100644 index 0000000..e716c19 --- /dev/null +++ b/crates/frameshift-catalog-postgres/migrations/2026-08-02-000001_harden_account_auth/up.sql @@ -0,0 +1,223 @@ +-- Expand-only first-party authentication hardening substrate. +-- Raw access, refresh, MFA, recovery, challenge, and authorization-code +-- secrets never enter these tables. Callers persist only SHA-256 digests or +-- authenticated ciphertext produced under deployment-managed keys. + +ALTER TABLE account_sessions + ADD COLUMN access_expires_at TIMESTAMPTZ; + +UPDATE account_sessions +SET access_expires_at = LEAST(idle_expires_at, absolute_expires_at); + +ALTER TABLE account_sessions + ALTER COLUMN access_expires_at SET NOT NULL, + ADD COLUMN mfa_verified_at TIMESTAMPTZ, + ADD CONSTRAINT account_session_access_expiry_order CHECK ( + access_expires_at > created_at + AND access_expires_at <= absolute_expires_at + ), + ADD CONSTRAINT account_session_mfa_time_order CHECK ( + mfa_verified_at IS NULL OR mfa_verified_at <= last_seen_at + ); + +COMMENT ON COLUMN account_sessions.token_digest IS + 'SHA-256 digest of the current short-lived access token'; + +CREATE INDEX account_sessions_active_access_idx + ON account_sessions (token_digest, access_expires_at) + WHERE revoked_at IS NULL; + +CREATE TABLE account_session_refresh_tokens ( + id UUID PRIMARY KEY, + session_id UUID NOT NULL REFERENCES account_sessions(id), + generation BIGINT NOT NULL CHECK (generation >= 0), + token_digest BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + CONSTRAINT account_refresh_token_digest_length CHECK ( + octet_length(token_digest) = 32 + ), + CONSTRAINT account_refresh_token_generation_unique UNIQUE ( + session_id, + generation + ), + CONSTRAINT account_refresh_token_time_order CHECK ( + expires_at > created_at + AND (consumed_at IS NULL OR consumed_at >= created_at) + ) +); + +CREATE INDEX account_refresh_token_session_history_idx + ON account_session_refresh_tokens (session_id, generation DESC); + +CREATE TABLE account_mfa_authenticators ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id), + state TEXT NOT NULL CHECK (state IN ('pending', 'active', 'disabled')), + secret_ciphertext BYTEA NOT NULL, + secret_nonce BYTEA NOT NULL, + secret_key_version SMALLINT NOT NULL CHECK (secret_key_version > 0), + pending_expires_at TIMESTAMPTZ, + last_used_timestep BIGINT, + created_at TIMESTAMPTZ NOT NULL, + activated_at TIMESTAMPTZ, + disabled_at TIMESTAMPTZ, + CONSTRAINT account_mfa_ciphertext_bounded CHECK ( + octet_length(secret_ciphertext) BETWEEN 16 AND 4096 + ), + CONSTRAINT account_mfa_nonce_length CHECK ( + octet_length(secret_nonce) = 24 + ), + CONSTRAINT account_mfa_state_shape CHECK ( + ( + state = 'pending' + AND pending_expires_at > created_at + AND activated_at IS NULL + AND disabled_at IS NULL + AND last_used_timestep IS NULL + ) OR ( + state = 'active' + AND pending_expires_at IS NULL + AND activated_at IS NOT NULL + AND activated_at >= created_at + AND disabled_at IS NULL + ) OR ( + state = 'disabled' + AND pending_expires_at IS NULL + AND disabled_at IS NOT NULL + AND disabled_at >= created_at + ) + ) +); + +CREATE UNIQUE INDEX account_mfa_one_pending_idx + ON account_mfa_authenticators (account_id) + WHERE state = 'pending'; + +CREATE UNIQUE INDEX account_mfa_one_active_idx + ON account_mfa_authenticators (account_id) + WHERE state = 'active'; + +CREATE TABLE account_mfa_recovery_codes ( + id UUID PRIMARY KEY, + authenticator_id UUID NOT NULL REFERENCES account_mfa_authenticators(id), + code_digest BYTEA NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + CONSTRAINT account_mfa_recovery_digest_length CHECK ( + octet_length(code_digest) = 32 + ), + CONSTRAINT account_mfa_recovery_time_order CHECK ( + consumed_at IS NULL OR consumed_at >= created_at + ) +); + +CREATE INDEX account_mfa_recovery_authenticator_idx + ON account_mfa_recovery_codes (authenticator_id, consumed_at); + +CREATE TABLE account_mfa_login_challenges ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id), + token_digest BYTEA NOT NULL UNIQUE, + client_kind TEXT NOT NULL CHECK (client_kind IN ('browser', 'desktop', 'cli')), + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + CONSTRAINT account_mfa_challenge_digest_length CHECK ( + octet_length(token_digest) = 32 + ), + CONSTRAINT account_mfa_challenge_time_order CHECK ( + expires_at > created_at + AND (consumed_at IS NULL OR consumed_at >= created_at) + ) +); + +CREATE INDEX account_mfa_challenge_account_idx + ON account_mfa_login_challenges (account_id, expires_at) + WHERE consumed_at IS NULL; + +CREATE TABLE account_native_authorization_codes ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id), + token_digest BYTEA NOT NULL UNIQUE, + client_kind TEXT NOT NULL CHECK (client_kind IN ('desktop', 'cli')), + redirect_uri TEXT NOT NULL, + pkce_challenge BYTEA NOT NULL, + mfa_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + CONSTRAINT account_native_code_digest_length CHECK ( + octet_length(token_digest) = 32 + ), + CONSTRAINT account_native_code_pkce_length CHECK ( + octet_length(pkce_challenge) = 32 + ), + CONSTRAINT account_native_code_redirect_bounded CHECK ( + char_length(redirect_uri) BETWEEN 1 AND 2048 + ), + CONSTRAINT account_native_code_time_order CHECK ( + expires_at > created_at + AND (mfa_verified_at IS NULL OR mfa_verified_at <= created_at) + AND (consumed_at IS NULL OR consumed_at >= created_at) + ) +); + +CREATE INDEX account_native_code_account_idx + ON account_native_authorization_codes (account_id, expires_at) + WHERE consumed_at IS NULL; + +CREATE TABLE account_auth_audit_events ( + id UUID PRIMARY KEY, + event_kind TEXT NOT NULL CHECK (event_kind IN ( + 'session_created', + 'session_refreshed', + 'session_replay_revoked', + 'mfa_enrollment_started', + 'mfa_enrollment_activated', + 'mfa_disabled', + 'mfa_challenge_created', + 'mfa_challenge_completed', + 'native_authorization_code_created', + 'native_authorization_code_consumed', + 'authentication_rejected' + )), + outcome TEXT NOT NULL CHECK (outcome IN ('success', 'rejected')), + account_id UUID REFERENCES accounts(id), + session_id UUID REFERENCES account_sessions(id), + client_kind TEXT CHECK (client_kind IN ('browser', 'desktop', 'cli')), + identifier_tag BYTEA, + network_tag BYTEA, + reason_code TEXT, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT account_auth_audit_identifier_tag_length CHECK ( + identifier_tag IS NULL OR octet_length(identifier_tag) = 32 + ), + CONSTRAINT account_auth_audit_network_tag_length CHECK ( + network_tag IS NULL OR octet_length(network_tag) = 32 + ), + CONSTRAINT account_auth_audit_reason_code_shape CHECK ( + reason_code IS NULL + OR reason_code ~ '^[a-z0-9][a-z0-9_.-]{0,63}$' + ) +); + +CREATE INDEX account_auth_audit_account_time_idx + ON account_auth_audit_events (account_id, created_at DESC, id); + +CREATE INDEX account_auth_audit_kind_time_idx + ON account_auth_audit_events (event_kind, created_at DESC, id); + +CREATE FUNCTION reject_account_auth_audit_event_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'account authentication audit events are immutable'; +END +$$; + +CREATE TRIGGER account_auth_audit_events_immutable +BEFORE UPDATE OR DELETE ON account_auth_audit_events +FOR EACH ROW EXECUTE FUNCTION reject_account_auth_audit_event_mutation(); diff --git a/crates/frameshift-catalog-postgres/src/backend.rs b/crates/frameshift-catalog-postgres/src/backend.rs index 5b25ee8..72f9f6d 100644 --- a/crates/frameshift-catalog-postgres/src/backend.rs +++ b/crates/frameshift-catalog-postgres/src/backend.rs @@ -17,22 +17,33 @@ //! Pool checkout failures are mapped by [`crate::errors::map_pool_error`]. use async_trait::async_trait; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Duration, Utc}; use diesel::prelude::*; use diesel_async::RunQueryDsl; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness as _}; use tracing::{debug, error, instrument}; +use uuid::Uuid; use frameshift_catalog::{ + AccountAuthAuditEventKind, AccountAuthAuditEventRecord, AccountAuthAuditOutcome, AccountInviteIssueRequest, AccountInviteRecord, AccountInviteRequestRecord, - AccountInviteReviewRequest, AccountInviteStatus, AccountPasswordCredentialRecord, - AccountPasswordRehashRequest, AccountRecord, AccountSessionRecord, AccountStatusChangeRequest, - AuthorRecord, CatalogBackend, CatalogError, Ed25519PublicKey, HealthStatus, - LocalAccountRegistrationRequest, LocalAccountRegistrationResult, MembershipState, PackRecord, - PackSearchFilters, PackSearchResult, PackStatus, PackVersionRecord, PlatformRole, - PlatformRoleAssignmentRequest, PlatformRoleRecord, PlatformRoleRevocationRequest, + AccountInviteReviewRequest, AccountInviteStatus, AccountMfaActivationRequest, + AccountMfaAuthenticatorRecord, AccountMfaAuthenticatorState, + AccountMfaChallengeCompletionRequest, AccountMfaChallengeCompletionResult, + AccountMfaChallengeCreationRequest, AccountMfaChallengeProof, AccountMfaDisableRequest, + AccountMfaEnrollmentRequest, AccountPasswordCredentialRecord, AccountPasswordRehashRequest, + AccountRecord, AccountSessionCreationRequest, AccountSessionIssuance, AccountSessionRecord, + AccountSessionRefreshRequest, AccountSessionRefreshResult, AccountStatus, + AccountStatusChangeRequest, AuthorRecord, CatalogBackend, CatalogError, Ed25519PublicKey, + EncryptedPasswordRecoveryDelivery, HealthStatus, LocalAccountRegistrationRequest, + LocalAccountRegistrationResult, MembershipState, NativeAuthorizationCodeCreationRequest, + NativeAuthorizationCodeExchangeRequest, NativeAuthorizationCodeExchangeResult, PackRecord, + PackSearchFilters, PackSearchResult, PackStatus, PackVersionRecord, + PasswordRecoveryCompletionRequest, PasswordRecoveryDeliveryClaimRequest, + PasswordRecoveryDeliveryKind, PasswordRecoveryDeliveryRecord, PasswordRecoveryEnqueueRequest, + PlatformRole, PlatformRoleAssignmentRequest, PlatformRoleRecord, PlatformRoleRevocationRequest, PublicationAppealCaseRecord, PublicationAppealCursor, PublicationAppealDisposition, PublicationAppealRecord, PublicationAppealRequest, PublicationAppealResolutionRecord, PublicationAppealResolutionRequest, PublicationIntentClaim, PublicationIntentRecord, @@ -51,11 +62,16 @@ use crate::config::PostgresCatalogConfig; use crate::errors::{map_diesel_error, map_migration_error, map_pool_error}; use crate::models::{ encode_text_enum, vec_to_pubkey, AccountInviteRequestRow, AccountInviteRow, - AccountPasswordCredentialRow, AccountRow, AccountSessionRow, AuthorRow, HandleRow, - NewAccountInviteRequestRow, NewAccountInviteRow, NewAccountPasswordCredentialRow, - NewAccountRow, NewAccountSessionRow, NewAuthorRow, NewHandleRow, NewPackDownloadRow, - NewPackRow, NewPackVersionRow, NewPublicationAppealResolutionRow, NewPublicationAppealRow, - NewPublicationIntentRow, NewPublicationLifecycleDecisionRow, + AccountMfaAuthenticatorRow, AccountMfaLoginChallengeRow, AccountPasswordCredentialRow, + AccountPasswordRecoveryDeliveryRow, AccountRow, AccountSessionRefreshTokenRow, + AccountSessionRow, AuthorRow, HandleRow, NativeAuthorizationCodeRow, + NewAccountAuthAuditEventRow, NewAccountInviteRequestRow, NewAccountInviteRow, + NewAccountMfaAuthenticatorRow, NewAccountMfaLoginChallengeRow, NewAccountMfaRecoveryCodeRow, + NewAccountPasswordCredentialRow, NewAccountPasswordRecoveryDeliveryRow, + NewAccountPasswordRecoveryTokenRow, NewAccountRow, NewAccountSessionRefreshTokenRow, + NewAccountSessionRow, NewAuthorRow, NewHandleRow, NewNativeAuthorizationCodeRow, + NewPackDownloadRow, NewPackRow, NewPackVersionRow, NewPublicationAppealResolutionRow, + NewPublicationAppealRow, NewPublicationIntentRow, NewPublicationLifecycleDecisionRow, NewPublicationModerationDecisionRow, NewPublicationPromotionRow, NewPublicationSubmissionRow, NewPublisherAuditEventRow, NewPublisherKeyRow, NewPublisherMembershipRow, NewPublisherProfileRow, PackRow, PackVersionRow, PlatformRoleRow, @@ -65,9 +81,12 @@ use crate::models::{ }; use crate::pool::{build_pool, PgPool}; use crate::schema::{ - account_invite_requests, account_invites, account_password_credentials, account_platform_roles, - account_sessions, accounts, authors, handles, pack_downloads, pack_versions, packs, - publication_appeal_resolutions, publication_appeals, publication_intents, + account_auth_audit_events, account_invite_requests, account_invites, + account_mfa_authenticators, account_mfa_login_challenges, account_mfa_recovery_codes, + account_native_authorization_codes, account_password_credentials, + account_password_recovery_outbox, account_password_recovery_tokens, account_platform_roles, + account_session_refresh_tokens, account_sessions, accounts, authors, handles, pack_downloads, + pack_versions, packs, publication_appeal_resolutions, publication_appeals, publication_intents, publication_lifecycle_decisions, publication_moderation_decisions, publication_promotions, publication_submissions, publisher_audit_events, publisher_keys, publisher_memberships, publisher_profiles, signed_request_nonces, @@ -130,6 +149,32 @@ enum CatalogTransactionError { Diesel(diesel::result::Error), } +/// Internal row-preserving result of a committed refresh-token transaction. +enum SessionRefreshTransactionResult { + /// Rotation committed and returned the updated session row. + Rotated(AccountSessionRow), + /// A consumed generation was replayed and family revocation committed. + ReplayRevoked, + /// The presented generation or session was unusable without a state change. + Rejected, +} + +/// Internal row-preserving result of an MFA challenge transaction. +enum MfaCompletionTransactionResult { + /// Challenge and proof consumption committed with this session row. + Completed(AccountSessionRow), + /// The challenge or proof was unusable and no state changed. + Rejected, +} + +/// Internal row-preserving result of a native authorization-code transaction. +enum NativeCodeExchangeTransactionResult { + /// Exact code and PKCE consumption committed with this session row. + Exchanged(AccountSessionRow), + /// The code or one of its exact bindings was unusable. + Rejected, +} + /// Convert raw Diesel failures into the shared transaction error wrapper. impl From for CatalogTransactionError { /// Preserve the Diesel error until the caller can attach resource context. @@ -138,6 +183,273 @@ impl From for CatalogTransactionError { } } +/// Validate the normalized email shape accepted by recovery persistence. +fn validate_password_recovery_email(normalized_email: &str) -> Result<(), CatalogError> { + let bytes = normalized_email.as_bytes(); + if bytes.len() < 3 + || bytes.len() > 320 + || normalized_email.trim() != normalized_email + || normalized_email.to_lowercase() != normalized_email + || !normalized_email.contains('@') + || normalized_email.chars().any(char::is_control) + { + return Err(CatalogError::Validation( + "password recovery email must be a normalized address".to_string(), + )); + } + Ok(()) +} + +/// Validate an opaque encrypted recovery payload before opening a transaction. +fn validate_password_recovery_delivery( + delivery: &EncryptedPasswordRecoveryDelivery, + created_at: DateTime, +) -> Result<(), CatalogError> { + if delivery.id.is_nil() + || !(16..=262_144).contains(&delivery.ciphertext.len()) + || delivery.key_version <= 0 + || delivery.expires_at <= created_at + { + return Err(CatalogError::Validation( + "encrypted password recovery delivery is invalid".to_string(), + )); + } + Ok(()) +} + +/// Validate one bounded provider message identifier without exposing payload data. +fn validate_password_recovery_provider_message_id( + provider_message_id: &str, +) -> Result<(), CatalogError> { + if provider_message_id.is_empty() + || provider_message_id.len() > 256 + || provider_message_id.trim() != provider_message_id + || provider_message_id.chars().any(char::is_control) + { + return Err(CatalogError::Validation( + "password recovery provider message id is invalid".to_string(), + )); + } + Ok(()) +} + +/// Validate one bounded static delivery error code. +fn validate_password_recovery_error_code(last_error_code: &str) -> Result<(), CatalogError> { + let bytes = last_error_code.as_bytes(); + let valid_head = !bytes.is_empty() + && bytes.len() <= 64 + && bytes + .first() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + let valid_tail = bytes.iter().skip(1).all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'.' | b':' | b'-') + }); + if !valid_head || !valid_tail { + return Err(CatalogError::Validation( + "password recovery error code must use 1-64 lowercase ASCII letters, digits, '.', '_', ':', or '-'" + .to_string(), + )); + } + Ok(()) +} + +/// Convert an encrypted delivery envelope into a new outbox row. +fn new_password_recovery_delivery_row( + account_id: uuid::Uuid, + recipient: String, + kind: PasswordRecoveryDeliveryKind, + delivery: EncryptedPasswordRecoveryDelivery, + created_at: DateTime, +) -> Result { + Ok(NewAccountPasswordRecoveryDeliveryRow { + id: delivery.id, + account_id, + kind: encode_text_enum(kind)?, + recipient, + ciphertext: delivery.ciphertext, + nonce: delivery.nonce.to_vec(), + key_version: delivery.key_version, + attempt_count: 0, + last_attempt_at: None, + claim_id: None, + claimed_at: None, + next_attempt_at: created_at, + expires_at: delivery.expires_at, + sent_at: None, + provider_message_id: None, + failed_at: None, + last_error_code: None, + created_at, + }) +} + +/// Validate one fixed-length cryptographic digest without exposing its bytes. +fn validate_auth_digest(digest: &[u8], kind: &str) -> Result<(), CatalogError> { + if digest.len() != 32 { + return Err(CatalogError::Validation(format!( + "{kind} digest must be 32 bytes" + ))); + } + Ok(()) +} + +/// Validate one bounded static account-auth audit reason code. +fn validate_auth_reason_code(reason_code: &str) -> Result<(), CatalogError> { + let bytes = reason_code.as_bytes(); + let valid_head = !bytes.is_empty() + && bytes.len() <= 64 + && bytes + .first() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + let valid_tail = bytes.iter().skip(1).all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b'-') + }); + if !valid_head || !valid_tail { + return Err(CatalogError::Validation( + "authentication audit reason code is invalid".to_string(), + )); + } + Ok(()) +} + +/// Validate that an audit event contains only the schema's bounded fields. +fn validate_auth_audit_event(event: &AccountAuthAuditEventRecord) -> Result<(), CatalogError> { + if event.id.is_nil() + || event + .identifier_tag + .as_ref() + .is_some_and(|tag| tag.len() != 32) + || event + .network_tag + .as_ref() + .is_some_and(|tag| tag.len() != 32) + { + return Err(CatalogError::Validation( + "authentication audit event metadata is invalid".to_string(), + )); + } + if let Some(reason_code) = event.reason_code.as_deref() { + validate_auth_reason_code(reason_code)?; + } + Ok(()) +} + +/// Require the exact success-event binding for one atomic security mutation. +fn validate_atomic_auth_audit_event( + event: &AccountAuthAuditEventRecord, + event_kind: AccountAuthAuditEventKind, + account_id: Uuid, + session_id: Option, + client_kind: Option, + created_at: DateTime, +) -> Result<(), CatalogError> { + validate_auth_audit_event(event)?; + if event.event_kind != event_kind + || event.outcome != AccountAuthAuditOutcome::Success + || event.account_id != Some(account_id) + || event.session_id != session_id + || event.client_kind != client_kind + || event.reason_code.is_some() + || event.created_at != created_at + { + return Err(CatalogError::Validation( + "authentication audit event does not match its state transition".to_string(), + )); + } + Ok(()) +} + +/// Validate one access-plus-refresh session issuance before a transaction. +fn validate_session_issuance(issuance: &AccountSessionIssuance) -> Result<(), CatalogError> { + let session = &issuance.session; + validate_auth_digest(&session.token_digest, "access token")?; + validate_auth_digest(&issuance.refresh_token_digest, "refresh token")?; + if session.id.is_nil() + || session.account_id.is_nil() + || issuance.refresh_token_id.is_nil() + || session.token_digest == issuance.refresh_token_digest + || session.revoked_at.is_some() + || session.last_seen_at < session.created_at + || session.access_expires_at <= session.created_at + || session.access_expires_at > session.idle_expires_at + || session.idle_expires_at > session.absolute_expires_at + || issuance.refresh_expires_at <= session.created_at + || issuance.refresh_expires_at > session.absolute_expires_at + || session + .mfa_verified_at + .is_some_and(|verified_at| verified_at > session.last_seen_at) + { + return Err(CatalogError::Validation( + "account session issuance is inconsistent".to_string(), + )); + } + Ok(()) +} + +/// Compare optional assurance timestamps at PostgreSQL's microsecond precision. +fn auth_assurance_timestamp_matches( + left: Option>, + right: Option>, +) -> bool { + left.map(|timestamp| timestamp.timestamp_micros()) + == right.map(|timestamp| timestamp.timestamp_micros()) +} + +/// Insert one session family and refresh generation zero in the current transaction. +async fn insert_session_issuance( + conn: &mut diesel_async::AsyncPgConnection, + issuance: AccountSessionIssuance, +) -> Result { + let session = issuance.session; + let row = diesel::insert_into(account_sessions::table) + .values(NewAccountSessionRow { + id: session.id, + account_id: session.account_id, + token_digest: session.token_digest, + client_kind: encode_text_enum(session.client_kind) + .map_err(CatalogTransactionError::Catalog)?, + created_at: session.created_at, + last_seen_at: session.last_seen_at, + access_expires_at: session.access_expires_at, + idle_expires_at: session.idle_expires_at, + absolute_expires_at: session.absolute_expires_at, + mfa_verified_at: session.mfa_verified_at, + revoked_at: session.revoked_at, + }) + .returning(AccountSessionRow::as_returning()) + .get_result(conn) + .await?; + diesel::insert_into(account_session_refresh_tokens::table) + .values(NewAccountSessionRefreshTokenRow { + id: issuance.refresh_token_id, + session_id: row.id, + generation: 0, + token_digest: issuance.refresh_token_digest, + created_at: row.created_at, + expires_at: issuance.refresh_expires_at, + consumed_at: None, + }) + .execute(conn) + .await?; + Ok(row) +} + +/// Insert one validated sanitized account-auth audit event in a transaction. +async fn insert_auth_audit_event( + conn: &mut diesel_async::AsyncPgConnection, + event: AccountAuthAuditEventRecord, +) -> Result<(), CatalogTransactionError> { + let row = NewAccountAuthAuditEventRow::from_record(event) + .map_err(CatalogTransactionError::Catalog)?; + diesel::insert_into(account_auth_audit_events::table) + .values(row) + .execute(conn) + .await?; + Ok(()) +} + /// Validate and convert a catalog audit record into its insertable row. fn new_publisher_audit_row( event: PublisherAuditEventRecord, @@ -811,16 +1123,18 @@ async fn require_active_administrator( Ok(()) } -/// Reject a role grant for an account that does not exist. +/// Lock the target account and reject a role grant when it does not exist. /// /// The foreign key would also reject it, but an explicit check keeps the error -/// specific instead of surfacing a constraint violation. +/// specific instead of surfacing a constraint violation. The row lock also +/// serializes the MFA prerequisite with concurrent MFA disable operations. async fn require_existing_account( conn: &mut diesel_async::AsyncPgConnection, account_id: uuid::Uuid, ) -> Result<(), CatalogTransactionError> { let exists = accounts::table .find(account_id) + .for_update() .select(accounts::id) .first::(conn) .await @@ -1490,10 +1804,11 @@ impl CatalogBackend for PostgresCatalog { &self, request: LocalAccountRegistrationRequest, ) -> Result { + validate_session_issuance(&request.session)?; if request.invite_token_digest.len() != 32 - || request.session.token_digest.len() != 32 + || request.account.status != AccountStatus::Active || request.credential.account_id != request.account.id - || request.session.account_id != request.account.id + || request.session.session.account_id != request.account.id || request.account.email.as_deref() != Some(request.credential.normalized_email.as_str()) || request.credential.email_verified_at.is_none() @@ -1502,14 +1817,21 @@ impl CatalogBackend for PostgresCatalog { "local registration records are inconsistent".to_string(), )); } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::SessionCreated, + request.account.id, + Some(request.session.session.id), + Some(request.session.session.client_kind), + request.session.session.created_at, + )?; let account_result = request.account.clone(); - let session_result = request.session.clone(); let account_key = request.account.id; let now = request.account.created_at; let mut conn = self.pool.get().await.map_err(map_pool_error)?; use diesel_async::AsyncConnection as _; let result = conn - .transaction::<(), CatalogTransactionError, _>(async move |conn| { + .transaction::(async move |conn| { let invitation = account_invites::table .filter(account_invites::token_digest.eq(&request.invite_token_digest)) .filter(account_invites::consumed_at.is_null()) @@ -1572,28 +1894,15 @@ impl CatalogBackend for PostgresCatalog { }) .execute(conn) .await?; - diesel::insert_into(account_sessions::table) - .values(NewAccountSessionRow { - id: request.session.id, - account_id: request.session.account_id, - token_digest: request.session.token_digest, - client_kind: encode_text_enum(request.session.client_kind) - .map_err(CatalogTransactionError::Catalog)?, - created_at: request.session.created_at, - last_seen_at: request.session.last_seen_at, - idle_expires_at: request.session.idle_expires_at, - absolute_expires_at: request.session.absolute_expires_at, - revoked_at: request.session.revoked_at, - }) - .execute(conn) - .await?; - Ok(()) + let session = insert_session_issuance(conn, request.session).await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(session) }) .await; match result { - Ok(()) => Ok(LocalAccountRegistrationResult { + Ok(session) => Ok(LocalAccountRegistrationResult { account: account_result, - session: session_result, + session: session.into_record()?, }), Err(CatalogTransactionError::Catalog(error)) => Err(error), Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( @@ -1676,128 +1985,1582 @@ impl CatalogBackend for PostgresCatalog { Ok(rows_affected == 1) } - /// Create one revocable first-party session after successful authentication. - async fn create_account_session( + /// Atomically create a digest-only reset token and encrypted delivery. + async fn enqueue_account_password_recovery( &self, - record: AccountSessionRecord, - ) -> Result<(), CatalogError> { - if record.token_digest.len() != 32 { + request: PasswordRecoveryEnqueueRequest, + ) -> Result { + validate_password_recovery_email(&request.normalized_email)?; + validate_password_recovery_delivery(&request.delivery, request.requested_at)?; + let maximum_expiry = request + .requested_at + .checked_add_signed(Duration::hours(24)) + .ok_or_else(|| { + CatalogError::Validation( + "password recovery request timestamp cannot represent its expiry".to_string(), + ) + })?; + if request.token_id.is_nil() + || request.token_digest.len() != 32 + || request.cooldown_cutoff > request.requested_at + || request.token_expires_at <= request.requested_at + || request.token_expires_at > maximum_expiry + || request.delivery.expires_at != request.token_expires_at + { return Err(CatalogError::Validation( - "session token digest must be 32 bytes".to_string(), + "password recovery enqueue request is invalid".to_string(), )); } - let key = record.id.to_string(); - let client_kind = encode_text_enum(record.client_kind)?; - let mut conn = self.pool.get().await.map_err(map_pool_error)?; - diesel::insert_into(account_sessions::table) - .values(NewAccountSessionRow { - id: record.id, - account_id: record.account_id, - token_digest: record.token_digest, - client_kind, - created_at: record.created_at, - last_seen_at: record.last_seen_at, - idle_expires_at: record.idle_expires_at, - absolute_expires_at: record.absolute_expires_at, - revoked_at: record.revoked_at, - }) - .execute(&mut conn) - .await - .map_err(|error| map_diesel_error(error, "account_session", key))?; - Ok(()) - } - /// Resolve one active first-party session by its opaque token digest. - async fn get_active_account_session( - &self, - token_digest: &[u8], - now: DateTime, - ) -> Result { - if token_digest.len() != 32 { - return Err(CatalogError::NotFound { - kind: "account_session", - key: "opaque-token".to_string(), - }); - } + let token_id = request.token_id; let mut conn = self.pool.get().await.map_err(map_pool_error)?; - account_sessions::table - .filter(account_sessions::token_digest.eq(token_digest)) - .filter(account_sessions::revoked_at.is_null()) - .filter(account_sessions::idle_expires_at.gt(now)) - .filter(account_sessions::absolute_expires_at.gt(now)) - .select(AccountSessionRow::as_select()) - .first(&mut conn) - .await - .map_err(|error| { - map_diesel_error(error, "account_session", "opaque-token".to_string()) - })? - .into_record() - } + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::(async move |conn| { + let credential = account_password_credentials::table + .inner_join(accounts::table) + .filter( + account_password_credentials::normalized_email + .eq(&request.normalized_email), + ) + .filter(account_password_credentials::email_verified_at.is_not_null()) + .filter(accounts::status.eq("active")) + .for_update() + .select(( + account_password_credentials::account_id, + account_password_credentials::normalized_email, + )) + .first::<(uuid::Uuid, String)>(conn) + .await + .optional()?; + let Some((account_id, recipient)) = credential else { + return Ok(false); + }; - /// Advance one active session's last-seen and sliding-expiry timestamps. - async fn touch_account_session( - &self, - session_id: uuid::Uuid, - last_seen_at: DateTime, - idle_expires_at: DateTime, - ) -> Result<(), CatalogError> { - let mut conn = self.pool.get().await.map_err(map_pool_error)?; - let rows = diesel::update( - account_sessions::table - .find(session_id) - .filter(account_sessions::revoked_at.is_null()) - .filter(account_sessions::absolute_expires_at.gt(last_seen_at)) - .filter(account_sessions::idle_expires_at.gt(last_seen_at)), - ) - .set(( - account_sessions::last_seen_at.eq(last_seen_at), - account_sessions::idle_expires_at.eq(idle_expires_at), - )) - .execute(&mut conn) - .await - .map_err(|error| map_diesel_error(error, "account_session", session_id.to_string()))?; - if rows == 0 { - return Err(CatalogError::NotFound { - kind: "account_session", - key: session_id.to_string(), - }); + let cooling_down = account_password_recovery_tokens::table + .filter(account_password_recovery_tokens::account_id.eq(account_id)) + .filter( + account_password_recovery_tokens::created_at.ge(request.cooldown_cutoff), + ) + .select(account_password_recovery_tokens::id) + .first::(conn) + .await + .optional()? + .is_some(); + if cooling_down { + return Ok(false); + } + + diesel::update( + account_password_recovery_outbox::table + .filter(account_password_recovery_outbox::account_id.eq(account_id)) + .filter(account_password_recovery_outbox::kind.eq("reset")) + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()), + ) + .set(( + account_password_recovery_outbox::failed_at.eq(request.requested_at), + account_password_recovery_outbox::last_error_code.eq("token_superseded"), + account_password_recovery_outbox::claim_id.eq(None::), + account_password_recovery_outbox::claimed_at.eq(None::>), + )) + .execute(conn) + .await?; + diesel::update( + account_password_recovery_tokens::table + .filter(account_password_recovery_tokens::account_id.eq(account_id)) + .filter(account_password_recovery_tokens::consumed_at.is_null()) + .filter(account_password_recovery_tokens::revoked_at.is_null()), + ) + .set(account_password_recovery_tokens::revoked_at.eq(request.requested_at)) + .execute(conn) + .await?; + + diesel::insert_into(account_password_recovery_tokens::table) + .values(NewAccountPasswordRecoveryTokenRow { + id: request.token_id, + account_id, + token_digest: request.token_digest, + created_at: request.requested_at, + expires_at: request.token_expires_at, + consumed_at: None, + revoked_at: None, + }) + .execute(conn) + .await?; + + let delivery = new_password_recovery_delivery_row( + account_id, + recipient, + PasswordRecoveryDeliveryKind::Reset, + request.delivery, + request.requested_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + diesel::insert_into(account_password_recovery_outbox::table) + .values(delivery) + .execute(conn) + .await?; + Ok(true) + }) + .await; + match result { + Ok(enqueued) => Ok(enqueued), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_password_recovery", + token_id.to_string(), + )), } - Ok(()) } - /// Revoke one session only when it belongs to the authenticated account. - async fn revoke_account_session( + /// Atomically consume a reset token, replace the credential, revoke every + /// active session, and enqueue an encrypted password-changed notice. + async fn complete_account_password_recovery( &self, - session_id: uuid::Uuid, - account_id: uuid::Uuid, - revoked_at: DateTime, - ) -> Result<(), CatalogError> { - let mut conn = self.pool.get().await.map_err(map_pool_error)?; - let rows = diesel::update( - account_sessions::table - .find(session_id) - .filter(account_sessions::account_id.eq(account_id)) - .filter(account_sessions::revoked_at.is_null()), - ) - .set(account_sessions::revoked_at.eq(revoked_at)) - .execute(&mut conn) - .await - .map_err(|error| map_diesel_error(error, "account_session", session_id.to_string()))?; - if rows == 0 { - return Err(CatalogError::NotFound { - kind: "account_session", - key: session_id.to_string(), - }); + request: PasswordRecoveryCompletionRequest, + ) -> Result { + if request.token_digest.len() != 32 { + return Ok(false); } - Ok(()) - } - - /// Create an OIDC-backed account with a unique identity pair. - #[instrument(skip(self, record), fields(account_id = %record.id, issuer = %record.issuer))] - async fn create_account(&self, record: AccountRecord) -> Result<(), CatalogError> { - if record.issuer.trim().is_empty() || record.subject.trim().is_empty() { + validate_password_recovery_delivery(&request.delivery, request.completed_at)?; + let required_delivery_expiry = request + .completed_at + .checked_add_signed(Duration::hours(24)) + .ok_or_else(|| { + CatalogError::Validation( + "password recovery completion timestamp cannot represent its expiry" + .to_string(), + ) + })?; + if !request.new_password_hash.starts_with("$argon2id$") + || request.new_password_hash.len() > 512 + || request.new_password_version <= 0 + || request.new_pepper_version <= 0 + || request.delivery.expires_at != required_delivery_expiry + { return Err(CatalogError::Validation( - "account issuer and subject must not be blank".to_string(), + "password recovery completion request is invalid".to_string(), + )); + } + + let delivery_id = request.delivery.id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::(async move |conn| { + let account_id = account_password_recovery_tokens::table + .filter( + account_password_recovery_tokens::token_digest.eq(&request.token_digest), + ) + .filter(account_password_recovery_tokens::consumed_at.is_null()) + .filter(account_password_recovery_tokens::revoked_at.is_null()) + .filter(account_password_recovery_tokens::created_at.le(request.completed_at)) + .filter(account_password_recovery_tokens::expires_at.gt(request.completed_at)) + .select(account_password_recovery_tokens::account_id) + .first::(conn) + .await + .optional()?; + let Some(account_id) = account_id else { + return Ok(false); + }; + + let credential = account_password_credentials::table + .inner_join(accounts::table) + .filter(account_password_credentials::account_id.eq(account_id)) + .filter(account_password_credentials::email_verified_at.is_not_null()) + .filter(accounts::status.eq("active")) + .for_update() + .select(( + account_password_credentials::account_id, + account_password_credentials::normalized_email, + )) + .first::<(uuid::Uuid, String)>(conn) + .await + .optional()?; + let Some((account_id, recipient)) = credential else { + return Ok(false); + }; + + let token_id = account_password_recovery_tokens::table + .filter( + account_password_recovery_tokens::token_digest.eq(&request.token_digest), + ) + .filter(account_password_recovery_tokens::account_id.eq(account_id)) + .filter(account_password_recovery_tokens::consumed_at.is_null()) + .filter(account_password_recovery_tokens::revoked_at.is_null()) + .filter(account_password_recovery_tokens::created_at.le(request.completed_at)) + .filter(account_password_recovery_tokens::expires_at.gt(request.completed_at)) + .for_update() + .select(account_password_recovery_tokens::id) + .first::(conn) + .await + .optional()?; + let Some(token_id) = token_id else { + return Ok(false); + }; + + diesel::update(account_password_credentials::table.find(account_id)) + .set(( + account_password_credentials::password_hash.eq(request.new_password_hash), + account_password_credentials::password_version + .eq(request.new_password_version), + account_password_credentials::pepper_version.eq(request.new_pepper_version), + account_password_credentials::password_changed_at.eq(request.completed_at), + account_password_credentials::updated_at.eq(request.completed_at), + )) + .execute(conn) + .await?; + diesel::update(account_password_recovery_tokens::table.find(token_id)) + .set(account_password_recovery_tokens::consumed_at.eq(request.completed_at)) + .execute(conn) + .await?; + diesel::update( + account_sessions::table + .filter(account_sessions::account_id.eq(account_id)) + .filter(account_sessions::revoked_at.is_null()), + ) + .set(account_sessions::revoked_at.eq(request.completed_at)) + .execute(conn) + .await?; + + diesel::update( + account_password_recovery_outbox::table + .filter(account_password_recovery_outbox::account_id.eq(account_id)) + .filter(account_password_recovery_outbox::kind.eq("reset")) + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()), + ) + .set(( + account_password_recovery_outbox::failed_at.eq(request.completed_at), + account_password_recovery_outbox::last_error_code.eq("token_consumed"), + account_password_recovery_outbox::claim_id.eq(None::), + account_password_recovery_outbox::claimed_at.eq(None::>), + )) + .execute(conn) + .await?; + + let delivery = new_password_recovery_delivery_row( + account_id, + recipient, + PasswordRecoveryDeliveryKind::PasswordChanged, + request.delivery, + request.completed_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + diesel::insert_into(account_password_recovery_outbox::table) + .values(delivery) + .execute(conn) + .await?; + Ok(true) + }) + .await; + match result { + Ok(completed) => Ok(completed), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_password_recovery", + delivery_id.to_string(), + )), + } + } + + /// Lease a bounded batch of ready encrypted deliveries under one claim UUID. + async fn claim_password_recovery_deliveries( + &self, + request: PasswordRecoveryDeliveryClaimRequest, + ) -> Result, CatalogError> { + if request.claim_id.is_nil() + || request.stale_before > request.claimed_at + || !(1..=100).contains(&request.limit) + { + return Err(CatalogError::Validation( + "password recovery delivery claim request is invalid".to_string(), + )); + } + + let claim_id = request.claim_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::, CatalogTransactionError, _>( + async move |conn| { + let ids = account_password_recovery_outbox::table + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()) + .filter( + account_password_recovery_outbox::next_attempt_at + .le(request.claimed_at), + ) + .filter(account_password_recovery_outbox::expires_at.gt(request.claimed_at)) + .filter(account_password_recovery_outbox::attempt_count.lt(1000)) + .filter(account_password_recovery_outbox::claim_id.is_null().or( + account_password_recovery_outbox::claimed_at.le(request.stale_before), + )) + .order(( + account_password_recovery_outbox::next_attempt_at.asc(), + account_password_recovery_outbox::created_at.asc(), + account_password_recovery_outbox::id.asc(), + )) + .limit(i64::from(request.limit)) + .for_update() + .skip_locked() + .select(account_password_recovery_outbox::id) + .load::(conn) + .await?; + if ids.is_empty() { + return Ok(Vec::new()); + } + + diesel::update( + account_password_recovery_outbox::table + .filter(account_password_recovery_outbox::id.eq_any(ids)), + ) + .set(( + account_password_recovery_outbox::attempt_count + .eq(account_password_recovery_outbox::attempt_count + 1), + account_password_recovery_outbox::last_attempt_at.eq(request.claimed_at), + account_password_recovery_outbox::claim_id.eq(request.claim_id), + account_password_recovery_outbox::claimed_at.eq(request.claimed_at), + )) + .returning(AccountPasswordRecoveryDeliveryRow::as_returning()) + .get_results(conn) + .await + .map_err(CatalogTransactionError::from) + }, + ) + .await; + match result { + Ok(rows) => { + let mut records = rows + .into_iter() + .map(AccountPasswordRecoveryDeliveryRow::into_record) + .collect::, _>>()?; + records + .sort_by_key(|record| (record.next_attempt_at, record.created_at, record.id)); + Ok(records) + } + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_password_recovery_delivery", + claim_id.to_string(), + )), + } + } + + /// Acknowledge one successful delivery only for its current fenced claim. + async fn mark_password_recovery_delivery_sent( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + sent_at: DateTime, + provider_message_id: String, + ) -> Result { + if delivery_id.is_nil() || claim_id.is_nil() { + return Err(CatalogError::Validation( + "password recovery delivery identifiers must be non-nil".to_string(), + )); + } + validate_password_recovery_provider_message_id(&provider_message_id)?; + + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let rows = diesel::update( + account_password_recovery_outbox::table + .find(delivery_id) + .filter(account_password_recovery_outbox::claim_id.eq(claim_id)) + .filter(account_password_recovery_outbox::claimed_at.le(sent_at)) + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()), + ) + .set(( + account_password_recovery_outbox::sent_at.eq(sent_at), + account_password_recovery_outbox::provider_message_id.eq(provider_message_id), + account_password_recovery_outbox::claim_id.eq(None::), + account_password_recovery_outbox::claimed_at.eq(None::>), + )) + .execute(&mut conn) + .await + .map_err(|error| { + map_diesel_error( + error, + "account_password_recovery_delivery", + delivery_id.to_string(), + ) + })?; + Ok(rows == 1) + } + + /// Release one fenced claim for a later retry with a sanitized error code. + async fn retry_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + next_attempt_at: DateTime, + last_error_code: String, + ) -> Result { + if delivery_id.is_nil() || claim_id.is_nil() { + return Err(CatalogError::Validation( + "password recovery delivery identifiers must be non-nil".to_string(), + )); + } + validate_password_recovery_error_code(&last_error_code)?; + + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let rows = diesel::update( + account_password_recovery_outbox::table + .find(delivery_id) + .filter(account_password_recovery_outbox::claim_id.eq(claim_id)) + .filter(account_password_recovery_outbox::claimed_at.lt(next_attempt_at)) + .filter(account_password_recovery_outbox::expires_at.gt(next_attempt_at)) + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()), + ) + .set(( + account_password_recovery_outbox::next_attempt_at.eq(next_attempt_at), + account_password_recovery_outbox::last_error_code.eq(last_error_code), + account_password_recovery_outbox::claim_id.eq(None::), + account_password_recovery_outbox::claimed_at.eq(None::>), + )) + .execute(&mut conn) + .await + .map_err(|error| { + map_diesel_error( + error, + "account_password_recovery_delivery", + delivery_id.to_string(), + ) + })?; + Ok(rows == 1) + } + + /// Permanently fail one delivery only for its current fenced claim. + async fn fail_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + failed_at: DateTime, + last_error_code: String, + ) -> Result { + if delivery_id.is_nil() || claim_id.is_nil() { + return Err(CatalogError::Validation( + "password recovery delivery identifiers must be non-nil".to_string(), + )); + } + validate_password_recovery_error_code(&last_error_code)?; + + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let rows = diesel::update( + account_password_recovery_outbox::table + .find(delivery_id) + .filter(account_password_recovery_outbox::claim_id.eq(claim_id)) + .filter(account_password_recovery_outbox::claimed_at.le(failed_at)) + .filter(account_password_recovery_outbox::sent_at.is_null()) + .filter(account_password_recovery_outbox::failed_at.is_null()), + ) + .set(( + account_password_recovery_outbox::failed_at.eq(failed_at), + account_password_recovery_outbox::last_error_code.eq(last_error_code), + account_password_recovery_outbox::claim_id.eq(None::), + account_password_recovery_outbox::claimed_at.eq(None::>), + )) + .execute(&mut conn) + .await + .map_err(|error| { + map_diesel_error( + error, + "account_password_recovery_delivery", + delivery_id.to_string(), + ) + })?; + Ok(rows == 1) + } + + /// Create one access-token session, refresh generation, and success audit atomically. + async fn create_account_session( + &self, + request: AccountSessionCreationRequest, + ) -> Result { + validate_session_issuance(&request.issuance)?; + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::SessionCreated, + request.issuance.session.account_id, + Some(request.issuance.session.id), + Some(request.issuance.session.client_kind), + request.issuance.session.created_at, + )?; + let session_id = request.issuance.session.id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::(async move |conn| { + let active_account = accounts::table + .find(request.issuance.session.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await + .optional()?; + if active_account.is_none() { + return Err(CatalogTransactionError::Catalog( + CatalogError::Unauthorized { + kind: "account_session", + key: "inactive-account".to_string(), + }, + )); + } + let session = insert_session_issuance(conn, request.issuance).await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(session) + }) + .await; + match result { + Ok(row) => row.into_record(), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_session", + session_id.to_string(), + )), + } + } + + /// Resolve one active first-party session by its opaque token digest. + async fn get_active_account_session( + &self, + token_digest: &[u8], + now: DateTime, + ) -> Result { + if token_digest.len() != 32 { + return Err(CatalogError::NotFound { + kind: "account_session", + key: "opaque-token".to_string(), + }); + } + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + account_sessions::table + .inner_join(accounts::table) + .filter(account_sessions::token_digest.eq(token_digest)) + .filter(accounts::status.eq("active")) + .filter(account_sessions::revoked_at.is_null()) + .filter(account_sessions::access_expires_at.gt(now)) + .filter(account_sessions::idle_expires_at.gt(now)) + .filter(account_sessions::absolute_expires_at.gt(now)) + .select(AccountSessionRow::as_select()) + .first(&mut conn) + .await + .map_err(|error| { + map_diesel_error(error, "account_session", "opaque-token".to_string()) + })? + .into_record() + } + + /// Advance one active session's last-seen and sliding-expiry timestamps. + async fn touch_account_session( + &self, + session_id: uuid::Uuid, + last_seen_at: DateTime, + idle_expires_at: DateTime, + ) -> Result<(), CatalogError> { + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let rows = diesel::update( + account_sessions::table + .find(session_id) + .filter(account_sessions::revoked_at.is_null()) + .filter(account_sessions::absolute_expires_at.gt(last_seen_at)) + .filter(account_sessions::idle_expires_at.gt(last_seen_at)), + ) + .set(( + account_sessions::last_seen_at.eq(last_seen_at), + account_sessions::idle_expires_at.eq(idle_expires_at), + )) + .execute(&mut conn) + .await + .map_err(|error| map_diesel_error(error, "account_session", session_id.to_string()))?; + if rows == 0 { + return Err(CatalogError::NotFound { + kind: "account_session", + key: session_id.to_string(), + }); + } + Ok(()) + } + + /// Revoke one session only when it belongs to the authenticated account. + async fn revoke_account_session( + &self, + session_id: uuid::Uuid, + account_id: uuid::Uuid, + revoked_at: DateTime, + ) -> Result<(), CatalogError> { + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + let rows = diesel::update( + account_sessions::table + .find(session_id) + .filter(account_sessions::account_id.eq(account_id)) + .filter(account_sessions::revoked_at.is_null()), + ) + .set(account_sessions::revoked_at.eq(revoked_at)) + .execute(&mut conn) + .await + .map_err(|error| map_diesel_error(error, "account_session", session_id.to_string()))?; + if rows == 0 { + return Err(CatalogError::NotFound { + kind: "account_session", + key: session_id.to_string(), + }); + } + Ok(()) + } + + /// Rotate one refresh generation or commit family revocation after replay. + async fn refresh_account_session( + &self, + request: AccountSessionRefreshRequest, + ) -> Result { + validate_auth_digest( + &request.presented_refresh_token_digest, + "presented refresh token", + )?; + validate_auth_digest( + &request.replacement_access_token_digest, + "replacement access token", + )?; + validate_auth_digest( + &request.replacement_refresh_token_digest, + "replacement refresh token", + )?; + validate_auth_audit_event(&request.success_audit_event)?; + validate_auth_audit_event(&request.replay_audit_event)?; + if request.replacement_refresh_token_id.is_nil() + || request.presented_refresh_token_digest == request.replacement_refresh_token_digest + || request.presented_refresh_token_digest == request.replacement_access_token_digest + || request.replacement_refresh_token_digest == request.replacement_access_token_digest + || request.replacement_access_expires_at <= request.rotated_at + || request.replacement_idle_expires_at < request.replacement_access_expires_at + || request.replacement_refresh_expires_at <= request.rotated_at + { + return Err(CatalogError::Validation( + "refresh rotation request is inconsistent".to_string(), + )); + } + + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::( + async move |conn| { + let refresh = account_session_refresh_tokens::table + .filter( + account_session_refresh_tokens::token_digest + .eq(&request.presented_refresh_token_digest), + ) + .for_update() + .select(AccountSessionRefreshTokenRow::as_select()) + .first(conn) + .await + .optional()?; + let Some(refresh) = refresh else { + return Ok(SessionRefreshTransactionResult::Rejected); + }; + let session = account_sessions::table + .find(refresh.session_id) + .for_update() + .select(AccountSessionRow::as_select()) + .first(conn) + .await?; + let account_is_active = accounts::table + .find(session.account_id) + .filter(accounts::status.eq("active")) + .select(accounts::id) + .first::(conn) + .await + .optional()? + .is_some(); + + if refresh.consumed_at.is_some() { + validate_atomic_auth_audit_event( + &request.replay_audit_event, + AccountAuthAuditEventKind::SessionReplayRevoked, + session.account_id, + Some(session.id), + Some( + serde_json::from_value(serde_json::Value::String( + session.client_kind.clone(), + )) + .map_err(|error| { + CatalogTransactionError::Catalog(CatalogError::BackendError( + Box::new(std::io::Error::other(error.to_string())), + )) + })?, + ), + request.rotated_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + if session.revoked_at.is_none() { + diesel::update(account_sessions::table.find(session.id)) + .set(account_sessions::revoked_at.eq(request.rotated_at)) + .execute(conn) + .await?; + } + insert_auth_audit_event(conn, request.replay_audit_event).await?; + return Ok(SessionRefreshTransactionResult::ReplayRevoked); + } + + if refresh.expires_at <= request.rotated_at + || !account_is_active + || session.revoked_at.is_some() + || session.idle_expires_at <= request.rotated_at + || session.absolute_expires_at <= request.rotated_at + || request.replacement_access_expires_at > session.absolute_expires_at + || request.replacement_idle_expires_at > session.absolute_expires_at + || request.replacement_refresh_expires_at > session.absolute_expires_at + { + return Ok(SessionRefreshTransactionResult::Rejected); + } + + let client_kind = serde_json::from_value(serde_json::Value::String( + session.client_kind.clone(), + )) + .map_err(|error| { + CatalogTransactionError::Catalog(CatalogError::BackendError(Box::new( + std::io::Error::other(error.to_string()), + ))) + })?; + validate_atomic_auth_audit_event( + &request.success_audit_event, + AccountAuthAuditEventKind::SessionRefreshed, + session.account_id, + Some(session.id), + Some(client_kind), + request.rotated_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + + diesel::update(account_session_refresh_tokens::table.find(refresh.id)) + .set(account_session_refresh_tokens::consumed_at.eq(request.rotated_at)) + .execute(conn) + .await?; + diesel::insert_into(account_session_refresh_tokens::table) + .values(NewAccountSessionRefreshTokenRow { + id: request.replacement_refresh_token_id, + session_id: session.id, + generation: refresh.generation + 1, + token_digest: request.replacement_refresh_token_digest, + created_at: request.rotated_at, + expires_at: request.replacement_refresh_expires_at, + consumed_at: None, + }) + .execute(conn) + .await?; + let updated = diesel::update(account_sessions::table.find(session.id)) + .set(( + account_sessions::token_digest + .eq(request.replacement_access_token_digest), + account_sessions::last_seen_at.eq(request.rotated_at), + account_sessions::access_expires_at + .eq(request.replacement_access_expires_at), + account_sessions::idle_expires_at + .eq(request.replacement_idle_expires_at), + )) + .returning(AccountSessionRow::as_returning()) + .get_result(conn) + .await?; + insert_auth_audit_event(conn, request.success_audit_event).await?; + Ok(SessionRefreshTransactionResult::Rotated(updated)) + }, + ) + .await; + match result { + Ok(SessionRefreshTransactionResult::Rotated(row)) => { + row.into_record().map(AccountSessionRefreshResult::Rotated) + } + Ok(SessionRefreshTransactionResult::ReplayRevoked) => { + Ok(AccountSessionRefreshResult::ReplayRevoked) + } + Ok(SessionRefreshTransactionResult::Rejected) => { + Ok(AccountSessionRefreshResult::Rejected) + } + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_session_refresh", + "opaque-token".to_string(), + )), + } + } + + /// Retrieve the single active encrypted TOTP authenticator for an account. + async fn get_active_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + ) -> Result { + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(account_id)) + .filter(account_mfa_authenticators::state.eq("active")) + .select(AccountMfaAuthenticatorRow::as_select()) + .first(&mut conn) + .await + .map_err(|error| { + map_diesel_error(error, "account_mfa_authenticator", account_id.to_string()) + })? + .into_record() + } + + /// Retrieve one unexpired pending encrypted TOTP authenticator by exact owner and identifier. + async fn get_pending_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + authenticator_id: uuid::Uuid, + now: DateTime, + ) -> Result { + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + account_mfa_authenticators::table + .find(authenticator_id) + .filter(account_mfa_authenticators::account_id.eq(account_id)) + .filter(account_mfa_authenticators::state.eq("pending")) + .filter(account_mfa_authenticators::pending_expires_at.gt(now)) + .select(AccountMfaAuthenticatorRow::as_select()) + .first(&mut conn) + .await + .map_err(|error| { + map_diesel_error( + error, + "account_mfa_authenticator", + authenticator_id.to_string(), + ) + })? + .into_record() + } + + /// Replace a pending TOTP enrollment and append its success audit atomically. + async fn begin_account_mfa_enrollment( + &self, + request: AccountMfaEnrollmentRequest, + ) -> Result { + let authenticator = &request.authenticator; + if authenticator.id.is_nil() + || authenticator.account_id.is_nil() + || authenticator.state != AccountMfaAuthenticatorState::Pending + || !(16..=4096).contains(&authenticator.secret.ciphertext.len()) + || authenticator.secret.key_version <= 0 + || authenticator + .pending_expires_at + .is_none_or(|expires_at| expires_at <= authenticator.created_at) + || authenticator.last_used_timestep.is_some() + || authenticator.activated_at.is_some() + || authenticator.disabled_at.is_some() + { + return Err(CatalogError::Validation( + "pending MFA authenticator is inconsistent".to_string(), + )); + } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::MfaEnrollmentStarted, + authenticator.account_id, + None, + None, + authenticator.created_at, + )?; + let account_id = authenticator.account_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::( + async move |conn| { + accounts::table + .find(request.authenticator.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await?; + diesel::update( + account_mfa_authenticators::table + .filter( + account_mfa_authenticators::account_id + .eq(request.authenticator.account_id), + ) + .filter(account_mfa_authenticators::state.eq("pending")), + ) + .set(( + account_mfa_authenticators::state.eq("disabled"), + account_mfa_authenticators::pending_expires_at.eq(None::>), + account_mfa_authenticators::disabled_at + .eq(Some(request.authenticator.created_at)), + )) + .execute(conn) + .await?; + let row = diesel::insert_into(account_mfa_authenticators::table) + .values(NewAccountMfaAuthenticatorRow { + id: request.authenticator.id, + account_id: request.authenticator.account_id, + state: "pending".to_string(), + secret_ciphertext: request.authenticator.secret.ciphertext, + secret_nonce: request.authenticator.secret.nonce.to_vec(), + secret_key_version: request.authenticator.secret.key_version, + pending_expires_at: request.authenticator.pending_expires_at, + last_used_timestep: None, + created_at: request.authenticator.created_at, + activated_at: None, + disabled_at: None, + }) + .returning(AccountMfaAuthenticatorRow::as_returning()) + .get_result(conn) + .await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(row) + }, + ) + .await; + match result { + Ok(row) => row.into_record(), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_mfa_authenticator", + account_id.to_string(), + )), + } + } + + /// Activate a pending TOTP authenticator, recovery codes, and audit atomically. + async fn activate_account_mfa( + &self, + request: AccountMfaActivationRequest, + ) -> Result { + let distinct_ids = request + .recovery_codes + .iter() + .map(|code| code.id) + .collect::>(); + let distinct_digests = request + .recovery_codes + .iter() + .map(|code| code.code_digest.clone()) + .collect::>(); + if request.account_id.is_nil() + || request.authenticator_id.is_nil() + || request.verified_timestep < 0 + || !(8..=16).contains(&request.recovery_codes.len()) + || distinct_ids.len() != request.recovery_codes.len() + || distinct_digests.len() != request.recovery_codes.len() + || request + .recovery_codes + .iter() + .any(|code| code.id.is_nil() || code.code_digest.len() != 32) + { + return Err(CatalogError::Validation( + "MFA activation request is inconsistent".to_string(), + )); + } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::MfaEnrollmentActivated, + request.account_id, + None, + None, + request.activated_at, + )?; + let account_id = request.account_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::( + async move |conn| { + accounts::table + .find(request.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await?; + let pending = account_mfa_authenticators::table + .find(request.authenticator_id) + .filter(account_mfa_authenticators::account_id.eq(request.account_id)) + .filter(account_mfa_authenticators::state.eq("pending")) + .filter( + account_mfa_authenticators::pending_expires_at.gt(request.activated_at), + ) + .for_update() + .select(AccountMfaAuthenticatorRow::as_select()) + .first(conn) + .await + .optional()?; + let Some(pending) = pending else { + return Err(CatalogTransactionError::Catalog( + CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: "invalid-or-expired".to_string(), + }, + )); + }; + diesel::update( + account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(request.account_id)) + .filter(account_mfa_authenticators::state.eq("active")), + ) + .set(( + account_mfa_authenticators::state.eq("disabled"), + account_mfa_authenticators::disabled_at.eq(Some(request.activated_at)), + )) + .execute(conn) + .await?; + let activated = + diesel::update(account_mfa_authenticators::table.find(pending.id)) + .set(( + account_mfa_authenticators::state.eq("active"), + account_mfa_authenticators::pending_expires_at + .eq(None::>), + account_mfa_authenticators::last_used_timestep + .eq(Some(request.verified_timestep)), + account_mfa_authenticators::activated_at + .eq(Some(request.activated_at)), + )) + .returning(AccountMfaAuthenticatorRow::as_returning()) + .get_result(conn) + .await?; + let recovery_rows = request + .recovery_codes + .into_iter() + .map(|code| NewAccountMfaRecoveryCodeRow { + id: code.id, + authenticator_id: pending.id, + code_digest: code.code_digest, + created_at: request.activated_at, + consumed_at: None, + }) + .collect::>(); + diesel::insert_into(account_mfa_recovery_codes::table) + .values(recovery_rows) + .execute(conn) + .await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(activated) + }, + ) + .await; + match result { + Ok(row) => row.into_record(), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_mfa_authenticator", + account_id.to_string(), + )), + } + } + + /// Disable active MFA only when no active privileged role would lose assurance. + async fn disable_account_mfa( + &self, + request: AccountMfaDisableRequest, + ) -> Result { + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::MfaDisabled, + request.account_id, + None, + None, + request.disabled_at, + )?; + let account_id = request.account_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::(async move |conn| { + accounts::table + .find(request.account_id) + .for_update() + .select(accounts::id) + .first::(conn) + .await?; + let privileged_roles = account_platform_roles::table + .filter(account_platform_roles::account_id.eq(request.account_id)) + .filter(account_platform_roles::state.eq("active")) + .count() + .get_result::(conn) + .await?; + if privileged_roles > 0 { + return Err(CatalogTransactionError::Catalog(CatalogError::Validation( + "active privileged roles require MFA".to_string(), + ))); + } + let rows = diesel::update( + account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(request.account_id)) + .filter(account_mfa_authenticators::state.eq("active")), + ) + .set(( + account_mfa_authenticators::state.eq("disabled"), + account_mfa_authenticators::disabled_at.eq(Some(request.disabled_at)), + )) + .execute(conn) + .await?; + if rows == 0 { + return Ok(false); + } + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(true) + }) + .await; + match result { + Ok(disabled) => Ok(disabled), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_mfa_authenticator", + account_id.to_string(), + )), + } + } + + /// Create a digest-only MFA challenge and its success audit atomically. + async fn create_account_mfa_challenge( + &self, + request: AccountMfaChallengeCreationRequest, + ) -> Result<(), CatalogError> { + validate_auth_digest(&request.challenge.token_digest, "MFA challenge")?; + if request.challenge.id.is_nil() + || request.challenge.account_id.is_nil() + || request.challenge.expires_at <= request.challenge.created_at + || request.challenge.consumed_at.is_some() + { + return Err(CatalogError::Validation( + "MFA challenge is inconsistent".to_string(), + )); + } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::MfaChallengeCreated, + request.challenge.account_id, + None, + Some(request.challenge.client_kind), + request.challenge.created_at, + )?; + let account_id = request.challenge.account_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::<(), CatalogTransactionError, _>(async move |conn| { + accounts::table + .find(request.challenge.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await?; + let active_authenticators = account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(request.challenge.account_id)) + .filter(account_mfa_authenticators::state.eq("active")) + .count() + .get_result::(conn) + .await?; + if active_authenticators != 1 { + return Err(CatalogTransactionError::Catalog( + CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }, + )); + } + diesel::insert_into(account_mfa_login_challenges::table) + .values(NewAccountMfaLoginChallengeRow { + id: request.challenge.id, + account_id: request.challenge.account_id, + token_digest: request.challenge.token_digest, + client_kind: encode_text_enum(request.challenge.client_kind) + .map_err(CatalogTransactionError::Catalog)?, + created_at: request.challenge.created_at, + expires_at: request.challenge.expires_at, + consumed_at: None, + }) + .execute(conn) + .await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(()) + }) + .await; + match result { + Ok(()) => Ok(()), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_mfa_challenge", + account_id.to_string(), + )), + } + } + + /// Consume one MFA challenge and proof while issuing a verified session family. + async fn complete_account_mfa_challenge( + &self, + request: AccountMfaChallengeCompletionRequest, + ) -> Result { + validate_auth_digest(&request.challenge_token_digest, "MFA challenge")?; + if let AccountMfaChallengeProof::RecoveryCodeDigest(digest) = &request.proof { + validate_auth_digest(digest, "MFA recovery code")?; + } + validate_session_issuance(&request.issuance)?; + if request.authenticator_id.is_nil() + || request.issuance.session.mfa_verified_at != Some(request.completed_at) + || request.issuance.session.created_at != request.completed_at + { + return Err(CatalogError::Validation( + "MFA session assurance is inconsistent".to_string(), + )); + } + validate_auth_audit_event(&request.audit_event)?; + let session_id = request.issuance.session.id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::( + async move |conn| { + let challenge = account_mfa_login_challenges::table + .filter( + account_mfa_login_challenges::token_digest + .eq(&request.challenge_token_digest), + ) + .for_update() + .select(AccountMfaLoginChallengeRow::as_select()) + .first(conn) + .await + .optional()?; + let Some(challenge) = challenge else { + return Ok(MfaCompletionTransactionResult::Rejected); + }; + let challenge = challenge + .into_record() + .map_err(CatalogTransactionError::Catalog)?; + if challenge.consumed_at.is_some() + || challenge.expires_at <= request.completed_at + || challenge.account_id != request.issuance.session.account_id + || challenge.client_kind != request.issuance.session.client_kind + { + return Ok(MfaCompletionTransactionResult::Rejected); + } + let account_is_active = accounts::table + .find(challenge.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await + .optional()? + .is_some(); + if !account_is_active { + return Ok(MfaCompletionTransactionResult::Rejected); + } + let authenticator = account_mfa_authenticators::table + .find(request.authenticator_id) + .filter(account_mfa_authenticators::account_id.eq(challenge.account_id)) + .filter(account_mfa_authenticators::state.eq("active")) + .for_update() + .select(AccountMfaAuthenticatorRow::as_select()) + .first(conn) + .await + .optional()?; + let Some(authenticator) = authenticator else { + return Ok(MfaCompletionTransactionResult::Rejected); + }; + + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::MfaChallengeCompleted, + challenge.account_id, + Some(request.issuance.session.id), + Some(challenge.client_kind), + request.completed_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + + match request.proof { + AccountMfaChallengeProof::TotpTimestep(timestep) => { + if timestep < 0 + || authenticator + .last_used_timestep + .is_some_and(|last_used| timestep <= last_used) + { + return Ok(MfaCompletionTransactionResult::Rejected); + } + diesel::update( + account_mfa_authenticators::table.find(authenticator.id), + ) + .set(account_mfa_authenticators::last_used_timestep.eq(Some(timestep))) + .execute(conn) + .await?; + } + AccountMfaChallengeProof::RecoveryCodeDigest(digest) => { + let rows = diesel::update( + account_mfa_recovery_codes::table + .filter( + account_mfa_recovery_codes::authenticator_id + .eq(authenticator.id), + ) + .filter(account_mfa_recovery_codes::code_digest.eq(digest)) + .filter(account_mfa_recovery_codes::consumed_at.is_null()), + ) + .set( + account_mfa_recovery_codes::consumed_at + .eq(Some(request.completed_at)), + ) + .execute(conn) + .await?; + if rows != 1 { + return Ok(MfaCompletionTransactionResult::Rejected); + } + } + } + diesel::update(account_mfa_login_challenges::table.find(challenge.id)) + .set( + account_mfa_login_challenges::consumed_at + .eq(Some(request.completed_at)), + ) + .execute(conn) + .await?; + let session = insert_session_issuance(conn, request.issuance).await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(MfaCompletionTransactionResult::Completed(session)) + }, + ) + .await; + match result { + Ok(MfaCompletionTransactionResult::Completed(row)) => row + .into_record() + .map(AccountMfaChallengeCompletionResult::Completed), + Ok(MfaCompletionTransactionResult::Rejected) => { + Ok(AccountMfaChallengeCompletionResult::Rejected) + } + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "account_mfa_challenge", + session_id.to_string(), + )), + } + } + + /// Issue one digest-only native authorization code with an atomic success audit. + async fn create_native_authorization_code( + &self, + request: NativeAuthorizationCodeCreationRequest, + ) -> Result<(), CatalogError> { + validate_auth_digest(&request.code.token_digest, "native authorization code")?; + validate_auth_digest(&request.code.pkce_challenge, "S256 PKCE challenge")?; + if request.code.id.is_nil() + || request.code.account_id.is_nil() + || request.code.client_kind == frameshift_catalog::AccountSessionClientKind::Browser + || request.code.redirect_uri.is_empty() + || request.code.redirect_uri.len() > 2048 + || request.code.redirect_uri.trim() != request.code.redirect_uri + || request.code.expires_at <= request.code.created_at + || request.code.consumed_at.is_some() + || request + .code + .mfa_verified_at + .is_some_and(|verified_at| verified_at > request.code.created_at) + { + return Err(CatalogError::Validation( + "native authorization code is inconsistent".to_string(), + )); + } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::NativeAuthorizationCodeCreated, + request.code.account_id, + None, + Some(request.code.client_kind), + request.code.created_at, + )?; + let account_id = request.code.account_id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::<(), CatalogTransactionError, _>(async move |conn| { + accounts::table + .find(request.code.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await?; + diesel::insert_into(account_native_authorization_codes::table) + .values(NewNativeAuthorizationCodeRow { + id: request.code.id, + account_id: request.code.account_id, + token_digest: request.code.token_digest, + client_kind: encode_text_enum(request.code.client_kind) + .map_err(CatalogTransactionError::Catalog)?, + redirect_uri: request.code.redirect_uri, + pkce_challenge: request.code.pkce_challenge, + mfa_verified_at: request.code.mfa_verified_at, + created_at: request.code.created_at, + expires_at: request.code.expires_at, + consumed_at: None, + }) + .execute(conn) + .await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(()) + }) + .await; + match result { + Ok(()) => Ok(()), + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "native_authorization_code", + account_id.to_string(), + )), + } + } + + /// Exchange one exact native code and S256 binding for a session family. + async fn exchange_native_authorization_code( + &self, + request: NativeAuthorizationCodeExchangeRequest, + ) -> Result { + validate_auth_digest(&request.code_token_digest, "native authorization code")?; + validate_auth_digest(&request.pkce_challenge, "S256 PKCE challenge")?; + validate_session_issuance(&request.issuance)?; + validate_auth_audit_event(&request.audit_event)?; + if request.client_kind == frameshift_catalog::AccountSessionClientKind::Browser + || request.redirect_uri.is_empty() + || request.redirect_uri.len() > 2048 + || request.redirect_uri.trim() != request.redirect_uri + || request.issuance.session.client_kind != request.client_kind + || request.issuance.session.created_at != request.exchanged_at + { + return Err(CatalogError::Validation( + "native authorization-code exchange is inconsistent".to_string(), + )); + } + let session_id = request.issuance.session.id; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + use diesel_async::AsyncConnection as _; + let result = conn + .transaction::( + async move |conn| { + let code = account_native_authorization_codes::table + .filter( + account_native_authorization_codes::token_digest + .eq(&request.code_token_digest), + ) + .for_update() + .select(NativeAuthorizationCodeRow::as_select()) + .first(conn) + .await + .optional()?; + let Some(code) = code else { + return Ok(NativeCodeExchangeTransactionResult::Rejected); + }; + let code = code + .into_record() + .map_err(CatalogTransactionError::Catalog)?; + if code.consumed_at.is_some() + || code.expires_at <= request.exchanged_at + || code.client_kind != request.client_kind + || code.redirect_uri != request.redirect_uri + || code.pkce_challenge != request.pkce_challenge + || code.account_id != request.issuance.session.account_id + || !auth_assurance_timestamp_matches( + code.mfa_verified_at, + request.issuance.session.mfa_verified_at, + ) + { + return Ok(NativeCodeExchangeTransactionResult::Rejected); + } + let account_is_active = accounts::table + .find(code.account_id) + .filter(accounts::status.eq("active")) + .for_update() + .select(accounts::id) + .first::(conn) + .await + .optional()? + .is_some(); + if !account_is_active { + return Ok(NativeCodeExchangeTransactionResult::Rejected); + } + validate_atomic_auth_audit_event( + &request.audit_event, + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + code.account_id, + Some(request.issuance.session.id), + Some(request.client_kind), + request.exchanged_at, + ) + .map_err(CatalogTransactionError::Catalog)?; + diesel::update(account_native_authorization_codes::table.find(code.id)) + .set( + account_native_authorization_codes::consumed_at + .eq(Some(request.exchanged_at)), + ) + .execute(conn) + .await?; + let session = insert_session_issuance(conn, request.issuance).await?; + insert_auth_audit_event(conn, request.audit_event).await?; + Ok(NativeCodeExchangeTransactionResult::Exchanged(session)) + }, + ) + .await; + match result { + Ok(NativeCodeExchangeTransactionResult::Exchanged(row)) => row + .into_record() + .map(NativeAuthorizationCodeExchangeResult::Exchanged), + Ok(NativeCodeExchangeTransactionResult::Rejected) => { + Ok(NativeAuthorizationCodeExchangeResult::Rejected) + } + Err(CatalogTransactionError::Catalog(error)) => Err(error), + Err(CatalogTransactionError::Diesel(error)) => Err(map_diesel_error( + error, + "native_authorization_code", + session_id.to_string(), + )), + } + } + + /// Append a sanitized rejection audit outside a successful state mutation. + async fn append_account_auth_audit_event( + &self, + event: AccountAuthAuditEventRecord, + ) -> Result<(), CatalogError> { + validate_auth_audit_event(&event)?; + if event.event_kind != AccountAuthAuditEventKind::AuthenticationRejected + || event.outcome != AccountAuthAuditOutcome::Rejected + || event.session_id.is_some() + || event.reason_code.is_none() + { + return Err(CatalogError::Validation( + "standalone authentication audit event must be a sanitized rejection".to_string(), + )); + } + let event_id = event.id; + let row = NewAccountAuthAuditEventRow::from_record(event)?; + let mut conn = self.pool.get().await.map_err(map_pool_error)?; + diesel::insert_into(account_auth_audit_events::table) + .values(row) + .execute(&mut conn) + .await + .map_err(|error| { + map_diesel_error(error, "account_auth_audit_event", event_id.to_string()) + })?; + Ok(()) + } + + /// Create an OIDC-backed account with a unique identity pair. + #[instrument(skip(self, record), fields(account_id = %record.id, issuer = %record.issuer))] + async fn create_account(&self, record: AccountRecord) -> Result<(), CatalogError> { + if record.issuer.trim().is_empty() || record.subject.trim().is_empty() { + return Err(CatalogError::Validation( + "account issuer and subject must not be blank".to_string(), )); } let mut conn = self.pool.get().await.map_err(map_pool_error)?; @@ -2710,6 +4473,17 @@ impl CatalogBackend for PostgresCatalog { require_active_administrator(conn, request.actor_account_id, "platform_role") .await?; require_existing_account(conn, request.account_id).await?; + let active_authenticators = account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(request.account_id)) + .filter(account_mfa_authenticators::state.eq("active")) + .count() + .get_result::(conn) + .await?; + if active_authenticators != 1 { + return Err(CatalogTransactionError::Catalog(CatalogError::Validation( + "moderator and administrator roles require active MFA".to_string(), + ))); + } let existing = active_or_revoked_role(conn, request.account_id, &role_text).await?; let now = Utc::now(); if let Some(existing) = existing { @@ -2854,14 +4628,11 @@ impl CatalogBackend for PostgresCatalog { key: request.account_id.to_string(), }) })?; - // Setting the status an account already holds is a no-op. - if target.status == status_text { - return Ok(target); - } // A non-active account grants no authority, so suspending the // sole administrator would strand the platform exactly as // revoking their role would. - if status_text != "active" + if target.status != status_text + && status_text != "active" && administrator_coverage(conn).await? <= 1 && account_holds_active_administrator(conn, request.account_id).await? { @@ -2869,15 +4640,33 @@ impl CatalogBackend for PostgresCatalog { "cannot suspend or disable the last active administrator".to_string(), ))); } - diesel::update(accounts::table.find(request.account_id)) - .set(( - accounts::status.eq(&status_text), - accounts::updated_at.eq(Utc::now()), - )) - .returning(AccountRow::as_returning()) - .get_result(conn) - .await - .map_err(CatalogTransactionError::from) + let updated = if target.status == status_text { + target + } else { + diesel::update(accounts::table.find(request.account_id)) + .set(( + accounts::status.eq(&status_text), + accounts::updated_at.eq(Utc::now()), + )) + .returning(AccountRow::as_returning()) + .get_result(conn) + .await? + }; + if status_text != "active" { + diesel::update( + account_sessions::table + .filter(account_sessions::account_id.eq(request.account_id)) + .filter(account_sessions::revoked_at.is_null()), + ) + .set(account_sessions::revoked_at.eq(diesel::dsl::sql::< + diesel::sql_types::Nullable, + >( + "GREATEST(account_sessions.created_at, CURRENT_TIMESTAMP)", + ))) + .execute(conn) + .await?; + } + Ok(updated) }) .await; match result { diff --git a/crates/frameshift-catalog-postgres/src/models.rs b/crates/frameshift-catalog-postgres/src/models.rs index a8314e5..ee2210a 100644 --- a/crates/frameshift-catalog-postgres/src/models.rs +++ b/crates/frameshift-catalog-postgres/src/models.rs @@ -17,11 +17,14 @@ use diesel::prelude::*; use serde_json::Value as JsonValue; use frameshift_catalog::{ - AccountInviteIntent, AccountInviteRecord, AccountInviteRequestRecord, AccountInviteStatus, - AccountPasswordCredentialRecord, AccountRecord, AccountSessionClientKind, AccountSessionRecord, - AccountStatus, AuthorRecord, CatalogError, Ed25519PublicKey, MembershipState, OauthLink, - ObjectHash, PackRecord, PackStatus, PackVersionRecord, PlatformRole, PlatformRoleRecord, - PlatformRoleState, PublicationAppealDisposition, PublicationAppealRecord, + AccountAuthAuditEventRecord, AccountInviteIntent, AccountInviteRecord, + AccountInviteRequestRecord, AccountInviteStatus, AccountMfaAuthenticatorRecord, + AccountMfaAuthenticatorState, AccountMfaLoginChallengeRecord, AccountPasswordCredentialRecord, + AccountRecord, AccountSessionClientKind, AccountSessionRecord, AccountStatus, AuthorRecord, + CatalogError, Ed25519PublicKey, EncryptedTotpSecret, MembershipState, + NativeAuthorizationCodeRecord, OauthLink, ObjectHash, PackRecord, PackStatus, + PackVersionRecord, PasswordRecoveryDeliveryKind, PasswordRecoveryDeliveryRecord, PlatformRole, + PlatformRoleRecord, PlatformRoleState, PublicationAppealDisposition, PublicationAppealRecord, PublicationAppealResolutionRecord, PublicationIntentRecord, PublicationLifecycleAction, PublicationLifecycleDecisionRecord, PublicationModerationAction, PublicationModerationDecisionRecord, PublicationPromotionRecord, PublicationSubmissionRecord, @@ -31,9 +34,12 @@ use frameshift_catalog::{ use uuid::Uuid; use crate::schema::{ - account_invite_requests, account_invites, account_password_credentials, account_platform_roles, - account_sessions, accounts, authors, handles, pack_downloads, pack_versions, packs, - publication_appeal_resolutions, publication_appeals, publication_intents, + account_auth_audit_events, account_invite_requests, account_invites, + account_mfa_authenticators, account_mfa_login_challenges, account_mfa_recovery_codes, + account_native_authorization_codes, account_password_credentials, + account_password_recovery_outbox, account_password_recovery_tokens, account_platform_roles, + account_session_refresh_tokens, account_sessions, accounts, authors, handles, pack_downloads, + pack_versions, packs, publication_appeal_resolutions, publication_appeals, publication_intents, publication_lifecycle_decisions, publication_moderation_decisions, publication_promotions, publication_submissions, publisher_audit_events, publisher_keys, publisher_memberships, publisher_profiles, @@ -271,10 +277,14 @@ pub(crate) struct AccountSessionRow { pub created_at: DateTime, /// Most recent authenticated-use timestamp. pub last_seen_at: DateTime, + /// Exclusive expiry of the current access token. + pub access_expires_at: DateTime, /// Sliding inactivity expiry timestamp. pub idle_expires_at: DateTime, /// Non-extendable absolute expiry timestamp. pub absolute_expires_at: DateTime, + /// Most recent second-factor verification inherited by this session. + pub mfa_verified_at: Option>, /// Explicit revocation timestamp. pub revoked_at: Option>, } @@ -295,14 +305,353 @@ pub(crate) struct NewAccountSessionRow { pub created_at: DateTime, /// Most recent authenticated-use timestamp. pub last_seen_at: DateTime, + /// Exclusive expiry of the current access token. + pub access_expires_at: DateTime, /// Sliding inactivity expiry timestamp. pub idle_expires_at: DateTime, /// Non-extendable absolute expiry timestamp. pub absolute_expires_at: DateTime, + /// Most recent second-factor verification inherited by this session. + pub mfa_verified_at: Option>, /// Explicit revocation timestamp. pub revoked_at: Option>, } +/// Queryable append-only refresh-token generation row. +#[derive(Queryable, Selectable)] +#[diesel(table_name = account_session_refresh_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct AccountSessionRefreshTokenRow { + /// Stable refresh-generation identifier. + pub id: Uuid, + /// Session family owning this generation. + pub session_id: Uuid, + /// Monotonically increasing family generation. + pub generation: i64, + /// Exclusive refresh-token expiry timestamp. + pub expires_at: DateTime, + /// Successful consumption or replay-observation timestamp. + pub consumed_at: Option>, +} + +/// Insertable append-only refresh-token generation row. +#[derive(Insertable)] +#[diesel(table_name = account_session_refresh_tokens)] +pub(crate) struct NewAccountSessionRefreshTokenRow { + /// Stable refresh-generation identifier. + pub id: Uuid, + /// Session family owning this generation. + pub session_id: Uuid, + /// Monotonically increasing family generation. + pub generation: i64, + /// SHA-256 digest of the random refresh token. + pub token_digest: Vec, + /// Refresh-token creation timestamp. + pub created_at: DateTime, + /// Exclusive refresh-token expiry timestamp. + pub expires_at: DateTime, + /// Successful consumption timestamp, absent for a newly issued token. + pub consumed_at: Option>, +} + +/// Queryable encrypted TOTP authenticator row. +#[derive(Queryable, Selectable)] +#[diesel(table_name = account_mfa_authenticators)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct AccountMfaAuthenticatorRow { + /// Stable authenticator identifier. + pub id: Uuid, + /// Account owning this authenticator. + pub account_id: Uuid, + /// Pending, active, or disabled lifecycle state. + pub state: String, + /// Opaque authenticated ciphertext containing the TOTP seed. + pub secret_ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub secret_nonce: Vec, + /// Deployment-managed encryption-key version. + pub secret_key_version: i16, + /// Exclusive deadline for confirming a pending enrollment. + pub pending_expires_at: Option>, + /// Greatest successfully consumed TOTP timestep. + pub last_used_timestep: Option, + /// Authenticator metadata creation timestamp. + pub created_at: DateTime, + /// Successful enrollment activation timestamp. + pub activated_at: Option>, + /// Authenticator disable timestamp. + pub disabled_at: Option>, +} + +/// Insertable encrypted TOTP authenticator row. +#[derive(Insertable)] +#[diesel(table_name = account_mfa_authenticators)] +pub(crate) struct NewAccountMfaAuthenticatorRow { + /// Stable authenticator identifier. + pub id: Uuid, + /// Account owning this authenticator. + pub account_id: Uuid, + /// Pending, active, or disabled lifecycle state. + pub state: String, + /// Opaque authenticated ciphertext containing the TOTP seed. + pub secret_ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub secret_nonce: Vec, + /// Deployment-managed encryption-key version. + pub secret_key_version: i16, + /// Exclusive deadline for confirming a pending enrollment. + pub pending_expires_at: Option>, + /// Greatest successfully consumed TOTP timestep. + pub last_used_timestep: Option, + /// Authenticator metadata creation timestamp. + pub created_at: DateTime, + /// Successful enrollment activation timestamp. + pub activated_at: Option>, + /// Authenticator disable timestamp. + pub disabled_at: Option>, +} + +/// Insertable digest-only MFA recovery-code row. +#[derive(Insertable)] +#[diesel(table_name = account_mfa_recovery_codes)] +pub(crate) struct NewAccountMfaRecoveryCodeRow { + /// Stable recovery-code identifier. + pub id: Uuid, + /// Authenticator that issued this recovery code. + pub authenticator_id: Uuid, + /// SHA-256 digest of the random recovery code. + pub code_digest: Vec, + /// Recovery-code creation timestamp. + pub created_at: DateTime, + /// Successful one-time consumption timestamp. + pub consumed_at: Option>, +} + +/// Queryable digest-only MFA login challenge row. +#[derive(Queryable, Selectable)] +#[diesel(table_name = account_mfa_login_challenges)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct AccountMfaLoginChallengeRow { + /// Stable challenge identifier. + pub id: Uuid, + /// Account that passed the first factor. + pub account_id: Uuid, + /// SHA-256 digest of the random challenge token. + pub token_digest: Vec, + /// Browser, desktop, or CLI client binding. + pub client_kind: String, + /// Challenge creation timestamp. + pub created_at: DateTime, + /// Exclusive challenge-completion deadline. + pub expires_at: DateTime, + /// Successful one-time completion timestamp. + pub consumed_at: Option>, +} + +/// Insertable digest-only MFA login challenge row. +#[derive(Insertable)] +#[diesel(table_name = account_mfa_login_challenges)] +pub(crate) struct NewAccountMfaLoginChallengeRow { + /// Stable challenge identifier. + pub id: Uuid, + /// Account that passed the first factor. + pub account_id: Uuid, + /// SHA-256 digest of the random challenge token. + pub token_digest: Vec, + /// Browser, desktop, or CLI client binding. + pub client_kind: String, + /// Challenge creation timestamp. + pub created_at: DateTime, + /// Exclusive challenge-completion deadline. + pub expires_at: DateTime, + /// Successful one-time completion timestamp. + pub consumed_at: Option>, +} + +/// Queryable digest-only native authorization-code row. +#[derive(Queryable, Selectable)] +#[diesel(table_name = account_native_authorization_codes)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct NativeAuthorizationCodeRow { + /// Stable authorization-code identifier. + pub id: Uuid, + /// Browser-authenticated account authorizing the native client. + pub account_id: Uuid, + /// SHA-256 digest of the random authorization code. + pub token_digest: Vec, + /// Desktop or CLI client binding. + pub client_kind: String, + /// Exact IP-literal loopback redirect URI string. + pub redirect_uri: String, + /// Decoded 32-byte S256 PKCE challenge. + pub pkce_challenge: Vec, + /// MFA assurance inherited from the browser session. + pub mfa_verified_at: Option>, + /// Authorization-code creation timestamp. + pub created_at: DateTime, + /// Exclusive authorization-code exchange deadline. + pub expires_at: DateTime, + /// Successful one-time exchange timestamp. + pub consumed_at: Option>, +} + +/// Insertable digest-only native authorization-code row. +#[derive(Insertable)] +#[diesel(table_name = account_native_authorization_codes)] +pub(crate) struct NewNativeAuthorizationCodeRow { + /// Stable authorization-code identifier. + pub id: Uuid, + /// Browser-authenticated account authorizing the native client. + pub account_id: Uuid, + /// SHA-256 digest of the random authorization code. + pub token_digest: Vec, + /// Desktop or CLI client binding. + pub client_kind: String, + /// Exact IP-literal loopback redirect URI string. + pub redirect_uri: String, + /// Decoded 32-byte S256 PKCE challenge. + pub pkce_challenge: Vec, + /// MFA assurance inherited from the browser session. + pub mfa_verified_at: Option>, + /// Authorization-code creation timestamp. + pub created_at: DateTime, + /// Exclusive authorization-code exchange deadline. + pub expires_at: DateTime, + /// Successful one-time exchange timestamp. + pub consumed_at: Option>, +} + +/// Insertable append-only sanitized authentication audit row. +#[derive(Insertable)] +#[diesel(table_name = account_auth_audit_events)] +pub(crate) struct NewAccountAuthAuditEventRow { + /// Stable event identifier. + pub id: Uuid, + /// Stable authentication event class. + pub event_kind: String, + /// Stable success or rejection outcome. + pub outcome: String, + /// Optional affected account identifier. + pub account_id: Option, + /// Optional affected session-family identifier. + pub session_id: Option, + /// Optional browser, desktop, or CLI client class. + pub client_kind: Option, + /// Optional keyed canonical-identifier digest. + pub identifier_tag: Option>, + /// Optional keyed canonical-network digest. + pub network_tag: Option>, + /// Optional bounded static reason code. + pub reason_code: Option, + /// Event creation timestamp. + pub created_at: DateTime, +} + +/// Insertable digest-only password-recovery token row. +#[derive(Insertable)] +#[diesel(table_name = account_password_recovery_tokens)] +pub(crate) struct NewAccountPasswordRecoveryTokenRow { + /// Stable internal recovery-token identifier. + pub id: Uuid, + /// Local account authorized by the token. + pub account_id: Uuid, + /// SHA-256 digest of the raw bearer token. + pub token_digest: Vec, + /// Token creation timestamp. + pub created_at: DateTime, + /// Exclusive token-consumption deadline. + pub expires_at: DateTime, + /// Successful one-time consumption timestamp. + pub consumed_at: Option>, + /// Explicit supersession or revocation timestamp. + pub revoked_at: Option>, +} + +/// Queryable encrypted password-recovery delivery row. +#[derive(Queryable, Selectable)] +#[diesel(table_name = account_password_recovery_outbox)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub(crate) struct AccountPasswordRecoveryDeliveryRow { + /// Stable outbox identifier and provider idempotency key. + pub id: Uuid, + /// Local account receiving the message. + pub account_id: Uuid, + /// Stable delivery purpose encoded as snake case. + pub kind: String, + /// Lowercase, trimmed destination email. + pub recipient: String, + /// Opaque authenticated ciphertext containing the message payload. + pub ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub nonce: Vec, + /// Deployment-managed encryption-key version. + pub key_version: i16, + /// Number of leases issued for this delivery. + pub attempt_count: i32, + /// Most recent lease-acquisition timestamp. + pub last_attempt_at: Option>, + /// UUID fencing the currently active worker lease. + pub claim_id: Option, + /// Timestamp at which the current worker lease began. + pub claimed_at: Option>, + /// Earliest timestamp at which an unclaimed worker may acquire the row. + pub next_attempt_at: DateTime, + /// Exclusive deadline after which the delivery must not be sent. + pub expires_at: DateTime, + /// Successful provider acknowledgement timestamp. + pub sent_at: Option>, + /// Bounded provider-assigned message identifier. + pub provider_message_id: Option, + /// Permanent failure timestamp. + pub failed_at: Option>, + /// Bounded static diagnostic code from the latest failed attempt. + pub last_error_code: Option, + /// Outbox row creation timestamp. + pub created_at: DateTime, +} + +/// Insertable encrypted password-recovery delivery row. +#[derive(Insertable)] +#[diesel(table_name = account_password_recovery_outbox)] +pub(crate) struct NewAccountPasswordRecoveryDeliveryRow { + /// Stable outbox identifier and provider idempotency key. + pub id: Uuid, + /// Local account receiving the message. + pub account_id: Uuid, + /// Stable delivery purpose encoded as snake case. + pub kind: String, + /// Lowercase, trimmed destination email. + pub recipient: String, + /// Opaque authenticated ciphertext containing the message payload. + pub ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub nonce: Vec, + /// Deployment-managed encryption-key version. + pub key_version: i16, + /// Number of leases issued for this delivery. + pub attempt_count: i32, + /// Most recent lease-acquisition timestamp. + pub last_attempt_at: Option>, + /// UUID fencing the currently active worker lease. + pub claim_id: Option, + /// Timestamp at which the current worker lease began. + pub claimed_at: Option>, + /// Earliest timestamp at which an unclaimed worker may acquire the row. + pub next_attempt_at: DateTime, + /// Exclusive deadline after which the delivery must not be sent. + pub expires_at: DateTime, + /// Successful provider acknowledgement timestamp. + pub sent_at: Option>, + /// Bounded provider-assigned message identifier. + pub provider_message_id: Option, + /// Permanent failure timestamp. + pub failed_at: Option>, + /// Bounded static diagnostic code from the latest failed attempt. + pub last_error_code: Option, + /// Outbox row creation timestamp. + pub created_at: DateTime, +} + /// Queryable public publisher profile row. #[derive(Debug, Queryable, Selectable)] #[diesel(table_name = publisher_profiles)] @@ -1051,6 +1400,29 @@ pub(crate) fn vec_to_hash(bytes: Vec) -> Result { Ok(ObjectHash::from_bytes(arr)) } +/// Convert a raw BYTEA value into a fixed XChaCha20-Poly1305 nonce. +/// +/// A length mismatch indicates database corruption because the migration also +/// enforces the 24-byte nonce length. +pub(crate) fn vec_to_recovery_nonce(bytes: Vec) -> Result<[u8; 24], CatalogError> { + bytes.try_into().map_err(|value: Vec| { + CatalogError::BackendError(Box::new(std::io::Error::other(format!( + "password recovery nonce in DB has wrong length: {} bytes", + value.len() + )))) + }) +} + +/// Convert encrypted TOTP nonce bytes into the fixed XChaCha20 nonce size. +pub(crate) fn vec_to_totp_nonce(bytes: Vec) -> Result<[u8; 24], CatalogError> { + bytes.try_into().map_err(|value: Vec| { + CatalogError::BackendError(Box::new(std::io::Error::other(format!( + "TOTP secret nonce in DB has wrong length: {} bytes", + value.len() + )))) + }) +} + /// Decode a serde string enum stored in a PostgreSQL TEXT column. fn parse_text_enum(value: String, kind: &str) -> Result where @@ -1164,13 +1536,135 @@ impl AccountSessionRow { )?, created_at: self.created_at, last_seen_at: self.last_seen_at, + access_expires_at: self.access_expires_at, idle_expires_at: self.idle_expires_at, absolute_expires_at: self.absolute_expires_at, + mfa_verified_at: self.mfa_verified_at, revoked_at: self.revoked_at, }) } } +/// Conversion helpers for encrypted TOTP authenticator rows. +impl AccountMfaAuthenticatorRow { + /// Convert database metadata into a typed encrypted authenticator record. + pub(crate) fn into_record(self) -> Result { + Ok(AccountMfaAuthenticatorRecord { + id: self.id, + account_id: self.account_id, + state: parse_text_enum::( + self.state, + "account MFA authenticator state", + )?, + secret: EncryptedTotpSecret { + ciphertext: self.secret_ciphertext, + nonce: vec_to_totp_nonce(self.secret_nonce)?, + key_version: self.secret_key_version, + }, + pending_expires_at: self.pending_expires_at, + last_used_timestep: self.last_used_timestep, + created_at: self.created_at, + activated_at: self.activated_at, + disabled_at: self.disabled_at, + }) + } +} + +/// Conversion helpers for digest-only MFA login challenge rows. +impl AccountMfaLoginChallengeRow { + /// Convert a database row into a typed MFA challenge record. + pub(crate) fn into_record(self) -> Result { + Ok(AccountMfaLoginChallengeRecord { + id: self.id, + account_id: self.account_id, + token_digest: self.token_digest, + client_kind: parse_text_enum::( + self.client_kind, + "MFA challenge client kind", + )?, + created_at: self.created_at, + expires_at: self.expires_at, + consumed_at: self.consumed_at, + }) + } +} + +/// Conversion helpers for digest-only native authorization-code rows. +impl NativeAuthorizationCodeRow { + /// Convert a database row into an exactly bound authorization-code record. + pub(crate) fn into_record(self) -> Result { + Ok(NativeAuthorizationCodeRecord { + id: self.id, + account_id: self.account_id, + token_digest: self.token_digest, + client_kind: parse_text_enum::( + self.client_kind, + "native authorization-code client kind", + )?, + redirect_uri: self.redirect_uri, + pkce_challenge: self.pkce_challenge, + mfa_verified_at: self.mfa_verified_at, + created_at: self.created_at, + expires_at: self.expires_at, + consumed_at: self.consumed_at, + }) + } +} + +/// Conversion helpers for sanitized account-auth audit records. +impl NewAccountAuthAuditEventRow { + /// Encode one typed audit record for insertion without accepting raw inputs. + pub(crate) fn from_record(record: AccountAuthAuditEventRecord) -> Result { + Ok(Self { + id: record.id, + event_kind: encode_text_enum(record.event_kind)?, + outcome: encode_text_enum(record.outcome)?, + account_id: record.account_id, + session_id: record.session_id, + client_kind: record.client_kind.map(encode_text_enum).transpose()?, + identifier_tag: record.identifier_tag, + network_tag: record.network_tag, + reason_code: record.reason_code, + created_at: record.created_at, + }) + } +} + +/// Conversion helpers for encrypted password-recovery delivery rows. +impl AccountPasswordRecoveryDeliveryRow { + /// Convert this database row into a typed delivery worker record. + pub(crate) fn into_record(self) -> Result { + let attempt_count = u32::try_from(self.attempt_count).map_err(|_| { + CatalogError::BackendError(Box::new(std::io::Error::other( + "password recovery attempt_count in DB is negative", + ))) + })?; + Ok(PasswordRecoveryDeliveryRecord { + id: self.id, + account_id: self.account_id, + kind: parse_text_enum::( + self.kind, + "password recovery delivery kind", + )?, + recipient: self.recipient, + ciphertext: self.ciphertext, + nonce: vec_to_recovery_nonce(self.nonce)?, + key_version: self.key_version, + attempt_count, + last_attempt_at: self.last_attempt_at, + claim_id: self.claim_id, + claimed_at: self.claimed_at, + next_attempt_at: self.next_attempt_at, + expires_at: self.expires_at, + sent_at: self.sent_at, + provider_message_id: self.provider_message_id, + failed_at: self.failed_at, + last_error_code: self.last_error_code, + created_at: self.created_at, + }) + } +} + /// Conversion helpers for global platform-role rows. impl PlatformRoleRow { /// Convert this database row into a typed platform-role record. diff --git a/crates/frameshift-catalog-postgres/src/schema.rs b/crates/frameshift-catalog-postgres/src/schema.rs index ad06670..7204abf 100644 --- a/crates/frameshift-catalog-postgres/src/schema.rs +++ b/crates/frameshift-catalog-postgres/src/schema.rs @@ -80,15 +80,217 @@ diesel::table! { created_at -> Timestamptz, /// Most recent authenticated-use timestamp. last_seen_at -> Timestamptz, + /// Exclusive expiry of the current short-lived access token. + access_expires_at -> Timestamptz, /// Sliding inactivity expiry timestamp. idle_expires_at -> Timestamptz, /// Non-extendable session expiry timestamp. absolute_expires_at -> Timestamptz, + /// Most recent second-factor verification inherited by the session. + mfa_verified_at -> Nullable, /// Explicit revocation timestamp. revoked_at -> Nullable, } } +diesel::table! { + /// Append-only refresh-token generations for revocable session families. + account_session_refresh_tokens (id) { + /// Stable refresh-generation identifier. + id -> Uuid, + /// Session family owning this generation. + session_id -> Uuid, + /// Monotonically increasing family generation. + generation -> BigInt, + /// SHA-256 digest of the random refresh token. + token_digest -> Binary, + /// Refresh-token creation timestamp. + created_at -> Timestamptz, + /// Exclusive refresh-token expiry timestamp. + expires_at -> Timestamptz, + /// Successful consumption or replay-observation timestamp. + consumed_at -> Nullable, + } +} + +diesel::table! { + /// Encrypted TOTP authenticator metadata and replay fence. + account_mfa_authenticators (id) { + /// Stable authenticator identifier. + id -> Uuid, + /// Account owning this authenticator. + account_id -> Uuid, + /// Pending, active, or disabled lifecycle state. + state -> Text, + /// Opaque authenticated ciphertext containing the TOTP seed. + secret_ciphertext -> Binary, + /// Random 192-bit XChaCha20-Poly1305 nonce. + secret_nonce -> Binary, + /// Deployment-managed encryption-key version. + secret_key_version -> SmallInt, + /// Exclusive deadline for confirming a pending enrollment. + pending_expires_at -> Nullable, + /// Greatest successfully consumed TOTP timestep. + last_used_timestep -> Nullable, + /// Authenticator metadata creation timestamp. + created_at -> Timestamptz, + /// Successful enrollment activation timestamp. + activated_at -> Nullable, + /// Authenticator disable timestamp. + disabled_at -> Nullable, + } +} + +diesel::table! { + /// Digest-only high-entropy recovery codes bound to one authenticator. + account_mfa_recovery_codes (id) { + /// Stable recovery-code identifier. + id -> Uuid, + /// Authenticator that issued the recovery code. + authenticator_id -> Uuid, + /// SHA-256 digest of the random recovery code. + code_digest -> Binary, + /// Recovery-code creation timestamp. + created_at -> Timestamptz, + /// Successful one-time consumption timestamp. + consumed_at -> Nullable, + } +} + +diesel::table! { + /// Digest-only password-bound challenges for MFA login completion. + account_mfa_login_challenges (id) { + /// Stable challenge identifier. + id -> Uuid, + /// Account that passed the first factor. + account_id -> Uuid, + /// SHA-256 digest of the random challenge token. + token_digest -> Binary, + /// Browser, desktop, or CLI client binding. + client_kind -> Text, + /// Challenge creation timestamp. + created_at -> Timestamptz, + /// Exclusive challenge-completion deadline. + expires_at -> Timestamptz, + /// Successful one-time completion timestamp. + consumed_at -> Nullable, + } +} + +diesel::table! { + /// Digest-only authorization codes bound to native S256 requests. + account_native_authorization_codes (id) { + /// Stable authorization-code identifier. + id -> Uuid, + /// Browser-authenticated account authorizing the native client. + account_id -> Uuid, + /// SHA-256 digest of the random authorization code. + token_digest -> Binary, + /// Desktop or CLI client binding. + client_kind -> Text, + /// Exact IP-literal loopback redirect URI string. + redirect_uri -> Text, + /// Decoded 32-byte S256 PKCE challenge. + pkce_challenge -> Binary, + /// MFA assurance inherited from the browser session. + mfa_verified_at -> Nullable, + /// Authorization-code creation timestamp. + created_at -> Timestamptz, + /// Exclusive authorization-code exchange deadline. + expires_at -> Timestamptz, + /// Successful one-time exchange timestamp. + consumed_at -> Nullable, + } +} + +diesel::table! { + /// Append-only sanitized first-party authentication audit events. + account_auth_audit_events (id) { + /// Stable event identifier. + id -> Uuid, + /// Stable authentication event class. + event_kind -> Text, + /// Stable success or rejection outcome. + outcome -> Text, + /// Optional affected account identifier. + account_id -> Nullable, + /// Optional affected session-family identifier. + session_id -> Nullable, + /// Optional browser, desktop, or CLI client class. + client_kind -> Nullable, + /// Optional keyed canonical-identifier digest. + identifier_tag -> Nullable, + /// Optional keyed canonical-network digest. + network_tag -> Nullable, + /// Optional bounded static reason code. + reason_code -> Nullable, + /// Event creation timestamp. + created_at -> Timestamptz, + } +} + +diesel::table! { + /// Single-use password-reset capabilities stored only as SHA-256 digests. + account_password_recovery_tokens (id) { + /// Stable internal recovery-token identifier. + id -> Uuid, + /// Local account authorized by the token. + account_id -> Uuid, + /// SHA-256 digest of the raw bearer token. + token_digest -> Binary, + /// Token creation timestamp. + created_at -> Timestamptz, + /// Exclusive token-consumption deadline. + expires_at -> Timestamptz, + /// Successful one-time consumption timestamp. + consumed_at -> Nullable, + /// Explicit supersession or revocation timestamp. + revoked_at -> Nullable, + } +} + +diesel::table! { + /// Encrypted recovery deliveries leased to background workers. + account_password_recovery_outbox (id) { + /// Stable outbox identifier and provider idempotency key. + id -> Uuid, + /// Local account receiving the recovery-related message. + account_id -> Uuid, + /// Stable reset or password-changed delivery purpose. + kind -> Text, + /// Lowercase, trimmed destination email. + recipient -> Text, + /// Opaque authenticated ciphertext containing the message payload. + ciphertext -> Binary, + /// Random 192-bit XChaCha20-Poly1305 nonce. + nonce -> Binary, + /// Deployment-managed encryption-key version. + key_version -> SmallInt, + /// Number of leases issued for this delivery. + attempt_count -> Integer, + /// Most recent lease-acquisition timestamp. + last_attempt_at -> Nullable, + /// UUID fencing the currently active worker lease. + claim_id -> Nullable, + /// Timestamp at which the current worker lease began. + claimed_at -> Nullable, + /// Earliest timestamp at which an unclaimed worker may acquire the row. + next_attempt_at -> Timestamptz, + /// Exclusive deadline after which the delivery must not be sent. + expires_at -> Timestamptz, + /// Successful provider acknowledgement timestamp. + sent_at -> Nullable, + /// Bounded provider-assigned message identifier. + provider_message_id -> Nullable, + /// Permanent failure timestamp. + failed_at -> Nullable, + /// Bounded static diagnostic code from the latest failed attempt. + last_error_code -> Nullable, + /// Outbox row creation timestamp. + created_at -> Timestamptz, + } +} + diesel::table! { /// Public artifact publisher profiles. publisher_profiles (id) { @@ -566,6 +768,9 @@ diesel::joinable!(publisher_memberships -> publisher_profiles (publisher_id)); // Allow Diesel join inference for first-party credentials and sessions. diesel::joinable!(account_password_credentials -> accounts (account_id)); diesel::joinable!(account_sessions -> accounts (account_id)); +// Recovery rows are anchored to the first-party credential primary key. +diesel::joinable!(account_password_recovery_tokens -> account_password_credentials (account_id)); +diesel::joinable!(account_password_recovery_outbox -> account_password_credentials (account_id)); // Account invitation joins are written explicitly where both optional foreign keys are needed. diesel::joinable!(account_invites -> account_invite_requests (request_id)); // Allow Diesel join inference for publisher keys. @@ -600,6 +805,14 @@ diesel::allow_tables_to_appear_in_same_query!( accounts, account_password_credentials, account_sessions, + account_session_refresh_tokens, + account_mfa_authenticators, + account_mfa_recovery_codes, + account_mfa_login_challenges, + account_native_authorization_codes, + account_auth_audit_events, + account_password_recovery_tokens, + account_password_recovery_outbox, account_invite_requests, account_invites, account_platform_roles, diff --git a/crates/frameshift-catalog-postgres/tests/postgres_integration.rs b/crates/frameshift-catalog-postgres/tests/postgres_integration.rs index 72622f7..a25fa4c 100644 --- a/crates/frameshift-catalog-postgres/tests/postgres_integration.rs +++ b/crates/frameshift-catalog-postgres/tests/postgres_integration.rs @@ -17,27 +17,41 @@ use std::time::Duration; use diesel::{ExpressionMethods as _, QueryDsl as _}; use diesel_async::{RunQueryDsl as _, SimpleAsyncConnection as _}; use frameshift_catalog::{ + AccountAuthAuditEventKind, AccountAuthAuditEventRecord, AccountAuthAuditOutcome, AccountInviteIntent, AccountInviteIssueRequest, AccountInviteRequestRecord, - AccountInviteStatus, AccountPasswordCredentialRecord, AccountPasswordRehashRequest, - AccountRecord, AccountSessionClientKind, AccountSessionRecord, AccountStatus, + AccountInviteStatus, AccountMfaActivationRequest, AccountMfaAuthenticatorRecord, + AccountMfaAuthenticatorState, AccountMfaChallengeCompletionRequest, + AccountMfaChallengeCompletionResult, AccountMfaChallengeCreationRequest, + AccountMfaChallengeProof, AccountMfaDisableRequest, AccountMfaEnrollmentRequest, + AccountMfaLoginChallengeRecord, AccountMfaRecoveryCodeSeed, AccountPasswordCredentialRecord, + AccountPasswordRehashRequest, AccountRecord, AccountSessionClientKind, + AccountSessionCreationRequest, AccountSessionIssuance, AccountSessionRecord, + AccountSessionRefreshRequest, AccountSessionRefreshResult, AccountStatus, AccountStatusChangeRequest, AuthorRecord, CatalogBackend, CatalogError, Ed25519PublicKey, - LocalAccountRegistrationRequest, MembershipState, ObjectHash, PackSearchFilters, PackStatus, - PackVersionRecord, PlatformRole, PlatformRoleAssignmentRequest, PlatformRoleRevocationRequest, - PlatformRoleState, PublicationAppealDisposition, PublicationAppealRequest, - PublicationAppealResolutionRequest, PublicationIntentClaim, PublicationIntentRecord, - PublicationLifecycleAction, PublicationLifecycleCursor, PublicationModerationAction, - PublicationModerationDecisionRequest, PublicationPromotionRequest, - PublicationSubmissionRequest, PublicationSubmissionState, PublicationTombstoneRequest, - PublicationWithdrawalRequest, PublishQuota, PublisherAuditEventRecord, PublisherKeyRecord, - PublisherKeyState, PublisherMembershipRecord, PublisherModerationStatus, - PublisherProfileRecord, PublisherRole, PublisherSuspensionRequest, SortMode, TombstoneReason, - TombstoneRecord, + EncryptedPasswordRecoveryDelivery, EncryptedTotpSecret, LocalAccountRegistrationRequest, + MembershipState, NativeAuthorizationCodeCreationRequest, + NativeAuthorizationCodeExchangeRequest, NativeAuthorizationCodeExchangeResult, + NativeAuthorizationCodeRecord, ObjectHash, PackSearchFilters, PackStatus, PackVersionRecord, + PasswordRecoveryCompletionRequest, PasswordRecoveryDeliveryClaimRequest, + PasswordRecoveryDeliveryKind, PasswordRecoveryEnqueueRequest, PlatformRole, + PlatformRoleAssignmentRequest, PlatformRoleRevocationRequest, PlatformRoleState, + PublicationAppealDisposition, PublicationAppealRequest, PublicationAppealResolutionRequest, + PublicationIntentClaim, PublicationIntentRecord, PublicationLifecycleAction, + PublicationLifecycleCursor, PublicationModerationAction, PublicationModerationDecisionRequest, + PublicationPromotionRequest, PublicationSubmissionRequest, PublicationSubmissionState, + PublicationTombstoneRequest, PublicationWithdrawalRequest, PublishQuota, + PublisherAuditEventRecord, PublisherKeyRecord, PublisherKeyState, PublisherMembershipRecord, + PublisherModerationStatus, PublisherProfileRecord, PublisherRole, PublisherSuspensionRequest, + SortMode, TombstoneReason, TombstoneRecord, }; use frameshift_catalog_postgres::schema::{ - account_invites, account_password_credentials, account_platform_roles, accounts, pack_versions, - publication_appeal_resolutions, publication_appeals, publication_lifecycle_decisions, - publication_moderation_decisions, publication_promotions, publication_submissions, - publisher_audit_events, publisher_keys, publisher_memberships, publisher_profiles, + account_auth_audit_events, account_invites, account_mfa_authenticators, + account_mfa_recovery_codes, account_password_credentials, account_password_recovery_outbox, + account_password_recovery_tokens, account_platform_roles, account_session_refresh_tokens, + account_sessions, accounts, pack_versions, publication_appeal_resolutions, publication_appeals, + publication_lifecycle_decisions, publication_moderation_decisions, publication_promotions, + publication_submissions, publisher_audit_events, publisher_keys, publisher_memberships, + publisher_profiles, }; use frameshift_catalog_postgres::{ OwnershipBackfillApplied, OwnershipBackfillManifest, OwnershipBackfillMode, @@ -158,8 +172,10 @@ fn make_local_registration( email: &str, subject: &str, ) -> LocalAccountRegistrationRequest { - let now = chrono::DateTime::from_timestamp_micros(chrono::Utc::now().timestamp_micros()) - .expect("current timestamp must fit"); + let database_precision_now = + chrono::DateTime::from_timestamp_micros(chrono::Utc::now().timestamp_micros()) + .expect("current timestamp must fit"); + let now = database_precision_now + chrono::Duration::nanoseconds(123); let account = AccountRecord { id: uuid::Uuid::new_v4(), issuer: "https://frameshift.test/first-party".to_string(), @@ -170,6 +186,7 @@ fn make_local_registration( created_at: now, updated_at: now, }; + let session_id = uuid::Uuid::new_v4(); LocalAccountRegistrationRequest { invite_token_digest: token_digest, credential: AccountPasswordCredentialRecord { @@ -183,90 +200,2083 @@ fn make_local_registration( password_changed_at: now, updated_at: now, }, - session: AccountSessionRecord { + session: AccountSessionIssuance { + session: AccountSessionRecord { + id: session_id, + account_id: account.id, + token_digest: Sha256::digest(subject.as_bytes()).to_vec(), + client_kind: AccountSessionClientKind::Desktop, + created_at: now, + last_seen_at: now, + access_expires_at: now + chrono::Duration::minutes(15), + idle_expires_at: now + chrono::Duration::hours(1), + absolute_expires_at: now + chrono::Duration::hours(2), + mfa_verified_at: None, + revoked_at: None, + }, + refresh_token_id: uuid::Uuid::new_v4(), + refresh_token_digest: Sha256::digest(format!("{subject}:refresh").as_bytes()).to_vec(), + refresh_expires_at: now + chrono::Duration::hours(2), + }, + audit_event: AccountAuthAuditEventRecord { id: uuid::Uuid::new_v4(), - account_id: account.id, - token_digest: Sha256::digest(subject.as_bytes()).to_vec(), - client_kind: AccountSessionClientKind::Desktop, + event_kind: AccountAuthAuditEventKind::SessionCreated, + outcome: AccountAuthAuditOutcome::Success, + account_id: Some(account.id), + session_id: Some(session_id), + client_kind: Some(AccountSessionClientKind::Desktop), + identifier_tag: None, + network_tag: None, + reason_code: None, created_at: now, - last_seen_at: now, - idle_expires_at: now + chrono::Duration::hours(1), - absolute_expires_at: now + chrono::Duration::hours(2), - revoked_at: None, }, account, } } -/// Verify PostgreSQL applies password rehashes only to the exact observed credential. +/// Build one deterministic encrypted recovery-delivery fixture. +fn make_password_recovery_delivery( + id: uuid::Uuid, + seed: u8, + expires_at: chrono::DateTime, +) -> EncryptedPasswordRecoveryDelivery { + EncryptedPasswordRecoveryDelivery { + id, + ciphertext: vec![seed; 32], + nonce: [seed.wrapping_add(1); 24], + key_version: 1, + expires_at, + } +} + +/// Build one reset-token enqueue fixture with a one-hour validity window. +fn make_password_recovery_enqueue( + normalized_email: &str, + token_id: uuid::Uuid, + delivery_id: uuid::Uuid, + token_digest: Vec, + requested_at: chrono::DateTime, + cooldown_cutoff: chrono::DateTime, + seed: u8, +) -> PasswordRecoveryEnqueueRequest { + let token_expires_at = requested_at + chrono::Duration::hours(1); + PasswordRecoveryEnqueueRequest { + token_id, + normalized_email: normalized_email.to_string(), + token_digest, + requested_at, + token_expires_at, + cooldown_cutoff, + delivery: make_password_recovery_delivery(delivery_id, seed, token_expires_at), + } +} + +/// Build one sanitized success audit event for an exact authentication mutation. +fn make_auth_success_audit( + event_kind: AccountAuthAuditEventKind, + account_id: uuid::Uuid, + session_id: Option, + client_kind: Option, + created_at: chrono::DateTime, +) -> AccountAuthAuditEventRecord { + AccountAuthAuditEventRecord { + id: uuid::Uuid::new_v4(), + event_kind, + outcome: AccountAuthAuditOutcome::Success, + account_id: Some(account_id), + session_id, + client_kind, + identifier_tag: Some(vec![0xA1; 32]), + network_tag: Some(vec![0xB2; 32]), + reason_code: None, + created_at, + } +} + +/// Build deterministic digest-only access and initial refresh credentials. +fn make_session_issuance( + account_id: uuid::Uuid, + client_kind: AccountSessionClientKind, + seed: u8, + created_at: chrono::DateTime, + mfa_verified_at: Option>, +) -> AccountSessionIssuance { + AccountSessionIssuance { + session: AccountSessionRecord { + id: uuid::Uuid::new_v4(), + account_id, + token_digest: vec![seed; 32], + client_kind, + created_at, + last_seen_at: created_at, + access_expires_at: created_at + chrono::Duration::minutes(10), + idle_expires_at: created_at + chrono::Duration::hours(1), + absolute_expires_at: created_at + chrono::Duration::hours(8), + mfa_verified_at, + revoked_at: None, + }, + refresh_token_id: uuid::Uuid::new_v4(), + refresh_token_digest: vec![seed.wrapping_add(1); 32], + refresh_expires_at: created_at + chrono::Duration::hours(8), + } +} + +/// Enroll and activate one encrypted test authenticator with recovery codes. +async fn enroll_test_mfa( + catalog: &PostgresCatalog, + account_id: uuid::Uuid, + seed: u8, + created_at: chrono::DateTime, +) -> ( + AccountMfaAuthenticatorRecord, + Vec, +) { + let authenticator_id = uuid::Uuid::new_v4(); + let pending = AccountMfaAuthenticatorRecord { + id: authenticator_id, + account_id, + state: AccountMfaAuthenticatorState::Pending, + secret: EncryptedTotpSecret { + ciphertext: vec![seed; 48], + nonce: [seed.wrapping_add(1); 24], + key_version: 1, + }, + pending_expires_at: Some(created_at + chrono::Duration::minutes(10)), + last_used_timestep: None, + created_at, + activated_at: None, + disabled_at: None, + }; + catalog + .begin_account_mfa_enrollment(AccountMfaEnrollmentRequest { + authenticator: pending, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaEnrollmentStarted, + account_id, + None, + None, + created_at, + ), + }) + .await + .expect("begin test MFA enrollment failed"); + let recovery_codes = (0_u8..8) + .map(|offset| AccountMfaRecoveryCodeSeed { + id: uuid::Uuid::new_v4(), + code_digest: vec![seed.wrapping_add(10).wrapping_add(offset); 32], + }) + .collect::>(); + let activated_at = created_at + chrono::Duration::seconds(1); + let active = catalog + .activate_account_mfa(AccountMfaActivationRequest { + account_id, + authenticator_id, + verified_timestep: 100, + recovery_codes: recovery_codes.clone(), + activated_at, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaEnrollmentActivated, + account_id, + None, + None, + activated_at, + ), + }) + .await + .expect("activate test MFA enrollment failed"); + (active, recovery_codes) +} + +/// Create one digest-only MFA challenge for a test client. +async fn create_test_mfa_challenge( + catalog: &PostgresCatalog, + account_id: uuid::Uuid, + client_kind: AccountSessionClientKind, + seed: u8, + created_at: chrono::DateTime, +) -> Vec { + let token_digest = vec![seed; 32]; + catalog + .create_account_mfa_challenge(AccountMfaChallengeCreationRequest { + challenge: AccountMfaLoginChallengeRecord { + id: uuid::Uuid::new_v4(), + account_id, + token_digest: token_digest.clone(), + client_kind, + created_at, + expires_at: created_at + chrono::Duration::minutes(5), + consumed_at: None, + }, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCreated, + account_id, + None, + Some(client_kind), + created_at, + ), + }) + .await + .expect("create test MFA challenge failed"); + token_digest +} + +/// Build one exact refresh rotation request with path-specific audit identifiers. +fn make_refresh_request( + account_id: uuid::Uuid, + session_id: uuid::Uuid, + client_kind: AccountSessionClientKind, + presented_digest: Vec, + seed: u8, + rotated_at: chrono::DateTime, + absolute_expires_at: chrono::DateTime, +) -> AccountSessionRefreshRequest { + AccountSessionRefreshRequest { + presented_refresh_token_digest: presented_digest, + replacement_access_token_digest: vec![seed; 32], + replacement_access_expires_at: rotated_at + chrono::Duration::minutes(10), + replacement_idle_expires_at: rotated_at + chrono::Duration::hours(1), + replacement_refresh_token_id: uuid::Uuid::new_v4(), + replacement_refresh_token_digest: vec![seed.wrapping_add(1); 32], + replacement_refresh_expires_at: absolute_expires_at, + rotated_at, + success_audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionRefreshed, + account_id, + Some(session_id), + Some(client_kind), + rotated_at, + ), + replay_audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionReplayRevoked, + account_id, + Some(session_id), + Some(client_kind), + rotated_at, + ), + } +} + +/// Verify PostgreSQL applies password rehashes only to the exact observed credential. +#[tokio::test] +#[ignore] +async fn password_rehash_is_compare_and_swap() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "password-rehash"); + catalog + .create_account(account.clone()) + .await + .expect("create password rehash account failed"); + let now = chrono::DateTime::from_timestamp_micros(chrono::Utc::now().timestamp_micros()) + .expect("current timestamp must fit"); + let old_hash = "$argon2id$v=19$m=19456,t=2,p=1$b2xk$cGFzcw".to_string(); + let new_hash = "$argon2id$v=19$m=65536,t=3,p=1$bmV3$cGFzcw".to_string(); + let mut connection = catalog + .pool() + .get() + .await + .expect("password rehash insert connection failed"); + diesel::insert_into(account_password_credentials::table) + .values(( + account_password_credentials::account_id.eq(account.id), + account_password_credentials::normalized_email.eq("password-rehash@example.test"), + account_password_credentials::password_hash.eq(old_hash.clone()), + account_password_credentials::password_version.eq(1_i16), + account_password_credentials::pepper_version.eq(1_i16), + account_password_credentials::email_verified_at.eq(Some(now)), + account_password_credentials::created_at.eq(now), + account_password_credentials::password_changed_at.eq(now), + account_password_credentials::updated_at.eq(now), + )) + .execute(&mut connection) + .await + .expect("insert password rehash credential failed"); + drop(connection); + + let updated_at = now + chrono::Duration::seconds(1); + let request = AccountPasswordRehashRequest { + account_id: account.id, + normalized_email: "password-rehash@example.test".to_string(), + expected_password_hash: old_hash, + expected_password_version: 1, + expected_pepper_version: 1, + expected_updated_at: now, + new_password_hash: new_hash.clone(), + new_password_version: 1, + new_pepper_version: 2, + updated_at, + }; + assert!(catalog + .rehash_account_password_credential(request.clone()) + .await + .expect("first password rehash failed")); + assert!( + !catalog + .rehash_account_password_credential(request) + .await + .expect("stale password rehash failed"), + "a stale rehash must not overwrite the upgraded credential" + ); + + let credential = catalog + .get_account_password_credential("password-rehash@example.test") + .await + .expect("load upgraded password credential failed"); + assert_eq!(credential.password_hash, new_hash); + assert_eq!(credential.pepper_version, 2); + assert_eq!(credential.password_changed_at, now); + assert_eq!(credential.updated_at, updated_at); +} + +/// Verify recovery mutations are atomic, single-use, digest-only, and claim-fenced. +#[tokio::test] +#[ignore = "requires Docker"] +async fn password_recovery_is_atomic_digest_only_and_claim_fenced() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "password-recovery"); + catalog + .create_account(account.clone()) + .await + .expect("create password recovery account failed"); + let now = chrono::DateTime::from_timestamp_micros(chrono::Utc::now().timestamp_micros()) + .expect("current timestamp must fit"); + let normalized_email = "password-recovery@example.test"; + let original_hash = "$argon2id$v=19$m=19456,t=2,p=1$b2xk$cGFzcw"; + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery fixture connection failed"); + diesel::insert_into(account_password_credentials::table) + .values(( + account_password_credentials::account_id.eq(account.id), + account_password_credentials::normalized_email.eq(normalized_email), + account_password_credentials::password_hash.eq(original_hash), + account_password_credentials::password_version.eq(1_i16), + account_password_credentials::pepper_version.eq(1_i16), + account_password_credentials::email_verified_at.eq(Some(now)), + account_password_credentials::created_at.eq(now), + account_password_credentials::password_changed_at.eq(now), + account_password_credentials::updated_at.eq(now), + )) + .execute(&mut connection) + .await + .expect("insert password recovery credential failed"); + for (seed, client_kind) in [(31_u8, "browser"), (32_u8, "desktop")] { + diesel::insert_into(account_sessions::table) + .values(( + account_sessions::id.eq(uuid::Uuid::new_v4()), + account_sessions::account_id.eq(account.id), + account_sessions::token_digest.eq(vec![seed; 32]), + account_sessions::client_kind.eq(client_kind), + account_sessions::created_at.eq(now), + account_sessions::last_seen_at.eq(now), + account_sessions::access_expires_at.eq(now + chrono::Duration::minutes(15)), + account_sessions::idle_expires_at.eq(now + chrono::Duration::hours(1)), + account_sessions::absolute_expires_at.eq(now + chrono::Duration::hours(2)), + account_sessions::mfa_verified_at.eq(None::>), + account_sessions::revoked_at.eq(None::>), + )) + .execute(&mut connection) + .await + .expect("insert password recovery session failed"); + } + + diesel::update(accounts::table.find(account.id)) + .set(accounts::status.eq("suspended")) + .execute(&mut connection) + .await + .expect("suspend password recovery account failed"); + drop(connection); + let suspended = make_password_recovery_enqueue( + normalized_email, + uuid::Uuid::new_v4(), + uuid::Uuid::new_v4(), + Sha256::digest(b"suspended-token").to_vec(), + now, + now - chrono::Duration::minutes(5), + 40, + ); + assert!( + !catalog + .enqueue_account_password_recovery(suspended) + .await + .expect("suspended recovery lookup failed"), + "a suspended account must be indistinguishable from a missing account" + ); + + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery eligibility connection failed"); + diesel::update(accounts::table.find(account.id)) + .set(accounts::status.eq("active")) + .execute(&mut connection) + .await + .expect("reactivate password recovery account failed"); + diesel::update(account_password_credentials::table.find(account.id)) + .set( + account_password_credentials::email_verified_at + .eq(None::>), + ) + .execute(&mut connection) + .await + .expect("clear recovery email verification failed"); + drop(connection); + let unverified = make_password_recovery_enqueue( + normalized_email, + uuid::Uuid::new_v4(), + uuid::Uuid::new_v4(), + Sha256::digest(b"unverified-token").to_vec(), + now + chrono::Duration::seconds(1), + now - chrono::Duration::minutes(5), + 41, + ); + assert!( + !catalog + .enqueue_account_password_recovery(unverified) + .await + .expect("unverified recovery lookup failed"), + "an unverified account must be indistinguishable from a missing account" + ); + + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery verification connection failed"); + diesel::update(account_password_credentials::table.find(account.id)) + .set(account_password_credentials::email_verified_at.eq(Some(now))) + .execute(&mut connection) + .await + .expect("verify recovery email failed"); + drop(connection); + + let raw_token = b"raw-password-reset-token"; + let first_token_id = uuid::Uuid::new_v4(); + let first_delivery_id = uuid::Uuid::new_v4(); + let first_requested_at = now + chrono::Duration::seconds(2); + let first = make_password_recovery_enqueue( + normalized_email, + first_token_id, + first_delivery_id, + Sha256::digest(raw_token).to_vec(), + first_requested_at, + first_requested_at - chrono::Duration::minutes(5), + 42, + ); + assert!(catalog + .enqueue_account_password_recovery(first) + .await + .expect("first recovery enqueue failed")); + + let second_requested_at = now + chrono::Duration::minutes(12); + let broken_token_digest = Sha256::digest(b"rollback-token").to_vec(); + let broken_supersede = make_password_recovery_enqueue( + normalized_email, + uuid::Uuid::new_v4(), + first_delivery_id, + broken_token_digest.clone(), + second_requested_at, + second_requested_at - chrono::Duration::minutes(5), + 43, + ); + assert!( + catalog + .enqueue_account_password_recovery(broken_supersede) + .await + .is_err(), + "an outbox conflict must fail the complete enqueue transaction" + ); + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery rollback connection failed"); + let first_revoked_at = account_password_recovery_tokens::table + .find(first_token_id) + .select(account_password_recovery_tokens::revoked_at) + .first::>>(&mut connection) + .await + .expect("load first recovery token after rollback failed"); + assert_eq!( + first_revoked_at, None, + "failed enqueue must roll back revocation" + ); + let broken_token_count = account_password_recovery_tokens::table + .filter(account_password_recovery_tokens::token_digest.eq(&broken_token_digest)) + .count() + .get_result::(&mut connection) + .await + .expect("count rolled-back recovery token failed"); + assert_eq!( + broken_token_count, 0, + "failed enqueue must not retain a token" + ); + drop(connection); + + let second_token_id = uuid::Uuid::new_v4(); + let second_delivery_id = uuid::Uuid::new_v4(); + let second_token_digest = Sha256::digest(b"active-reset-token").to_vec(); + let second = make_password_recovery_enqueue( + normalized_email, + second_token_id, + second_delivery_id, + second_token_digest.clone(), + second_requested_at, + second_requested_at - chrono::Duration::minutes(5), + 44, + ); + assert!(catalog + .enqueue_account_password_recovery(second.clone()) + .await + .expect("second recovery enqueue failed")); + let cooling_down = make_password_recovery_enqueue( + normalized_email, + uuid::Uuid::new_v4(), + uuid::Uuid::new_v4(), + Sha256::digest(b"cooldown-token").to_vec(), + second_requested_at + chrono::Duration::minutes(1), + second_requested_at - chrono::Duration::minutes(4), + 45, + ); + assert!( + !catalog + .enqueue_account_password_recovery(cooling_down) + .await + .expect("cooldown recovery enqueue failed"), + "cooldown must use the same generic false result" + ); + + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery token assertion connection failed"); + let token_states = account_password_recovery_tokens::table + .filter(account_password_recovery_tokens::id.eq_any([first_token_id, second_token_id])) + .order(account_password_recovery_tokens::created_at.asc()) + .select(( + account_password_recovery_tokens::id, + account_password_recovery_tokens::token_digest, + account_password_recovery_tokens::consumed_at, + account_password_recovery_tokens::revoked_at, + )) + .load::<( + uuid::Uuid, + Vec, + Option>, + Option>, + )>(&mut connection) + .await + .expect("load recovery token states failed"); + assert_eq!(token_states.len(), 2); + assert_eq!(token_states[0].0, first_token_id); + assert!( + token_states[0].3.is_some(), + "superseded token must be revoked" + ); + assert_eq!(token_states[1].0, second_token_id); + assert_eq!(token_states[1].2, None); + assert_eq!(token_states[1].3, None); + assert!(token_states.iter().all(|(_, digest, _, _)| { + !digest + .windows(raw_token.len()) + .any(|window| window == raw_token) + })); + drop(connection); + + let completed_at = second_requested_at + chrono::Duration::minutes(2); + let changed_hash = "$argon2id$v=19$m=65536,t=3,p=1$bmV3$cGFzcw".to_string(); + let conflicting_completion = PasswordRecoveryCompletionRequest { + token_digest: second_token_digest.clone(), + new_password_hash: changed_hash.clone(), + new_password_version: 2, + new_pepper_version: 3, + completed_at, + delivery: make_password_recovery_delivery( + first_delivery_id, + 46, + completed_at + chrono::Duration::hours(24), + ), + }; + assert!( + catalog + .complete_account_password_recovery(conflicting_completion) + .await + .is_err(), + "a completion outbox conflict must roll back every credential mutation" + ); + let credential = catalog + .get_account_password_credential(normalized_email) + .await + .expect("load credential after rolled-back completion failed"); + assert_eq!(credential.password_hash, original_hash); + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery completion rollback connection failed"); + let active_token_count = account_password_recovery_tokens::table + .find(second_token_id) + .filter(account_password_recovery_tokens::consumed_at.is_null()) + .filter(account_password_recovery_tokens::revoked_at.is_null()) + .count() + .get_result::(&mut connection) + .await + .expect("count active token after completion rollback failed"); + assert_eq!(active_token_count, 1); + let pre_completion_revocations = account_sessions::table + .filter(account_sessions::account_id.eq(account.id)) + .filter(account_sessions::revoked_at.is_not_null()) + .count() + .get_result::(&mut connection) + .await + .expect("count sessions after completion rollback failed"); + assert_eq!(pre_completion_revocations, 0); + drop(connection); + + let completion_delivery_id = uuid::Uuid::new_v4(); + let completion = PasswordRecoveryCompletionRequest { + token_digest: second_token_digest, + new_password_hash: changed_hash.clone(), + new_password_version: 2, + new_pepper_version: 3, + completed_at, + delivery: make_password_recovery_delivery( + completion_delivery_id, + 47, + completed_at + chrono::Duration::hours(24), + ), + }; + let (first_completion, concurrent_completion) = tokio::join!( + catalog.complete_account_password_recovery(completion.clone()), + catalog.complete_account_password_recovery(completion.clone()), + ); + let completion_results = [first_completion, concurrent_completion]; + assert_eq!( + completion_results + .iter() + .filter(|result| matches!(result, Ok(true))) + .count(), + 1, + "exactly one concurrent reset completion may consume the token" + ); + assert_eq!( + completion_results + .iter() + .filter(|result| matches!(result, Ok(false))) + .count(), + 1, + "the concurrent loser must receive the generic false result" + ); + assert!( + !catalog + .complete_account_password_recovery(completion) + .await + .expect("replayed recovery completion failed"), + "a consumed token must remain single-use" + ); + + let credential = catalog + .get_account_password_credential(normalized_email) + .await + .expect("load completed password credential failed"); + assert_eq!(credential.password_hash, changed_hash); + assert_eq!(credential.password_version, 2); + assert_eq!(credential.pepper_version, 3); + assert_eq!(credential.password_changed_at, completed_at); + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery outcome connection failed"); + let revoked_sessions = account_sessions::table + .filter(account_sessions::account_id.eq(account.id)) + .select(account_sessions::revoked_at) + .load::>>(&mut connection) + .await + .expect("load recovery session revocations failed"); + assert_eq!(revoked_sessions.len(), 2); + assert!(revoked_sessions.iter().all(Option::is_some)); + let persisted_ciphertexts = account_password_recovery_outbox::table + .select(account_password_recovery_outbox::ciphertext) + .load::>(&mut connection) + .await + .expect("load encrypted recovery payloads failed"); + assert_eq!(persisted_ciphertexts.len(), 3); + assert!(persisted_ciphertexts.iter().all(|ciphertext| { + !ciphertext + .windows(raw_token.len()) + .any(|window| window == raw_token) + })); + drop(connection); + + let first_claim_id = uuid::Uuid::new_v4(); + let first_claimed_at = completed_at + chrono::Duration::seconds(1); + let claimed = catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id: first_claim_id, + claimed_at: first_claimed_at, + stale_before: first_claimed_at - chrono::Duration::minutes(1), + limit: 10, + }) + .await + .expect("claim recovery delivery batch failed"); + assert_eq!( + claimed.len(), + 1, + "superseded and consumed reset deliveries must not remain claimable" + ); + assert!(claimed.iter().all(|delivery| { + delivery.claim_id == Some(first_claim_id) && delivery.attempt_count == 1 + })); + let completion_delivery = claimed + .iter() + .find(|delivery| delivery.id == completion_delivery_id) + .expect("completion delivery must be claimed"); + assert_eq!( + completion_delivery.kind, + PasswordRecoveryDeliveryKind::PasswordChanged + ); + assert!( + !catalog + .mark_password_recovery_delivery_sent( + completion_delivery_id, + uuid::Uuid::new_v4(), + first_claimed_at + chrono::Duration::seconds(1), + "provider-wrong-claim".to_string(), + ) + .await + .expect("wrong-claim acknowledgement failed"), + "an obsolete claim must not acknowledge a delivery" + ); + let retry_at = first_claimed_at + chrono::Duration::seconds(10); + assert!(catalog + .retry_password_recovery_delivery( + completion_delivery_id, + first_claim_id, + retry_at, + "provider_500".to_string(), + ) + .await + .expect("release reset delivery for retry failed")); + let too_early = catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id: uuid::Uuid::new_v4(), + claimed_at: retry_at - chrono::Duration::seconds(1), + stale_before: retry_at - chrono::Duration::minutes(1), + limit: 10, + }) + .await + .expect("early recovery delivery claim failed"); + assert!(too_early.is_empty(), "retry delay must be durable"); + + let second_claim_id = uuid::Uuid::new_v4(); + let second_claim = catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id: second_claim_id, + claimed_at: retry_at, + stale_before: retry_at - chrono::Duration::minutes(1), + limit: 10, + }) + .await + .expect("retry recovery delivery claim failed"); + assert_eq!(second_claim.len(), 1); + assert_eq!(second_claim[0].id, completion_delivery_id); + assert_eq!(second_claim[0].attempt_count, 2); + + let stale_reclaim_at = retry_at + chrono::Duration::minutes(2); + let third_claim_id = uuid::Uuid::new_v4(); + let stale_reclaim = catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id: third_claim_id, + claimed_at: stale_reclaim_at, + stale_before: stale_reclaim_at - chrono::Duration::minutes(1), + limit: 10, + }) + .await + .expect("stale recovery delivery reclaim failed"); + assert_eq!(stale_reclaim.len(), 1); + assert_eq!(stale_reclaim[0].id, completion_delivery_id); + assert_eq!(stale_reclaim[0].claim_id, Some(third_claim_id)); + assert_eq!(stale_reclaim[0].attempt_count, 3); + assert!( + !catalog + .mark_password_recovery_delivery_sent( + completion_delivery_id, + second_claim_id, + stale_reclaim_at + chrono::Duration::seconds(1), + "provider-obsolete-claim".to_string(), + ) + .await + .expect("obsolete recovery delivery acknowledgement failed"), + "a stale worker must be fenced after reclaim" + ); + assert!(catalog + .mark_password_recovery_delivery_sent( + completion_delivery_id, + third_claim_id, + stale_reclaim_at + chrono::Duration::seconds(1), + "provider-message-123".to_string(), + ) + .await + .expect("acknowledge reclaimed completion delivery failed")); + + let third_requested_at = second_requested_at + chrono::Duration::minutes(30); + let third_delivery_id = uuid::Uuid::new_v4(); + let third = make_password_recovery_enqueue( + normalized_email, + uuid::Uuid::new_v4(), + third_delivery_id, + Sha256::digest(b"provider-failure-token").to_vec(), + third_requested_at, + third_requested_at - chrono::Duration::minutes(5), + 48, + ); + assert!(catalog + .enqueue_account_password_recovery(third) + .await + .expect("enqueue permanent-failure recovery fixture failed")); + let failure_claim_id = uuid::Uuid::new_v4(); + let failure_claimed_at = third_requested_at + chrono::Duration::seconds(1); + let failure_claim = catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id: failure_claim_id, + claimed_at: failure_claimed_at, + stale_before: failure_claimed_at - chrono::Duration::minutes(1), + limit: 10, + }) + .await + .expect("claim permanent-failure recovery fixture failed"); + assert_eq!(failure_claim.len(), 1); + assert_eq!(failure_claim[0].id, third_delivery_id); + assert!( + !catalog + .fail_password_recovery_delivery( + third_delivery_id, + uuid::Uuid::new_v4(), + failure_claimed_at + chrono::Duration::seconds(1), + "provider_rejected".to_string(), + ) + .await + .expect("wrong-claim permanent failure update failed"), + "a non-owning claim must not permanently fail a delivery" + ); + assert!(catalog + .fail_password_recovery_delivery( + third_delivery_id, + failure_claim_id, + failure_claimed_at + chrono::Duration::seconds(1), + "provider_rejected".to_string(), + ) + .await + .expect("permanently fail recovery delivery failed")); + + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery lifecycle assertion connection failed"); + let completion_state = account_password_recovery_outbox::table + .find(completion_delivery_id) + .select(( + account_password_recovery_outbox::sent_at, + account_password_recovery_outbox::provider_message_id, + account_password_recovery_outbox::failed_at, + )) + .first::<( + Option>, + Option, + Option>, + )>(&mut connection) + .await + .expect("load completion delivery lifecycle failed"); + assert!(completion_state.0.is_some()); + assert_eq!(completion_state.1.as_deref(), Some("provider-message-123")); + assert_eq!(completion_state.2, None); + let reset_state = account_password_recovery_outbox::table + .find(third_delivery_id) + .select(( + account_password_recovery_outbox::attempt_count, + account_password_recovery_outbox::claim_id, + account_password_recovery_outbox::failed_at, + account_password_recovery_outbox::last_error_code, + )) + .first::<( + i32, + Option, + Option>, + Option, + )>(&mut connection) + .await + .expect("load failed reset delivery lifecycle failed"); + assert_eq!(reset_state.0, 1); + assert_eq!(reset_state.1, None); + assert!(reset_state.2.is_some()); + assert_eq!(reset_state.3.as_deref(), Some("provider_rejected")); + let cancelled_reset_codes = account_password_recovery_outbox::table + .filter( + account_password_recovery_outbox::id.eq_any([first_delivery_id, second_delivery_id]), + ) + .order(account_password_recovery_outbox::created_at.asc()) + .select(account_password_recovery_outbox::last_error_code) + .load::>(&mut connection) + .await + .expect("load cancelled reset delivery codes failed"); + assert_eq!( + cancelled_reset_codes, + vec![ + Some("token_superseded".to_string()), + Some("token_consumed".to_string()), + ] + ); +} + +/// Prove the recovery migration can be reverted and reapplied on a migrated database. +#[tokio::test] +#[ignore = "requires Docker"] +async fn password_recovery_migration_down_and_up_are_reversible() { + let (catalog, _container) = setup_catalog().await; + let mut connection = catalog + .pool() + .get() + .await + .expect("password recovery migration connection failed"); + + connection + .batch_execute(include_str!( + "../migrations/2026-08-02-000000_add_account_password_recovery/down.sql" + )) + .await + .expect("password recovery migration down failed"); + connection + .batch_execute(include_str!( + "../migrations/2026-08-02-000000_add_account_password_recovery/up.sql" + )) + .await + .expect("password recovery migration reapply failed"); +} + +/// Prove refresh generations rotate, remain replay-detectable, and revoke the family. +#[tokio::test] +#[ignore = "requires Docker"] +async fn refresh_rotation_retains_history_and_revokes_on_any_generation_replay() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "refresh-history"); + catalog + .create_account(account.clone()) + .await + .expect("create refresh-history account failed"); + let now = chrono::Utc::now(); + let issuance = + make_session_issuance(account.id, AccountSessionClientKind::Desktop, 11, now, None); + let session_id = issuance.session.id; + let initial_refresh = issuance.refresh_token_digest.clone(); + let persisted_session = catalog + .create_account_session(AccountSessionCreationRequest { + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionCreated, + account.id, + Some(session_id), + Some(AccountSessionClientKind::Desktop), + now, + ), + issuance, + }) + .await + .expect("create refresh-history session failed"); + let absolute_expires_at = persisted_session.absolute_expires_at; + + let first_rotated_at = now + chrono::Duration::minutes(1); + let first_request = make_refresh_request( + account.id, + session_id, + AccountSessionClientKind::Desktop, + initial_refresh.clone(), + 21, + first_rotated_at, + absolute_expires_at, + ); + let first_refresh = first_request.replacement_refresh_token_digest.clone(); + assert!(matches!( + catalog + .refresh_account_session(first_request) + .await + .expect("first refresh rotation failed"), + AccountSessionRefreshResult::Rotated(_) + )); + + let second_rotated_at = first_rotated_at + chrono::Duration::minutes(1); + let second_request = make_refresh_request( + account.id, + session_id, + AccountSessionClientKind::Desktop, + first_refresh, + 31, + second_rotated_at, + absolute_expires_at, + ); + let newest_access = second_request.replacement_access_token_digest.clone(); + assert!(matches!( + catalog + .refresh_account_session(second_request) + .await + .expect("second refresh rotation failed"), + AccountSessionRefreshResult::Rotated(_) + )); + + let replayed_at = second_rotated_at + chrono::Duration::minutes(1); + let replay = make_refresh_request( + account.id, + session_id, + AccountSessionClientKind::Desktop, + initial_refresh, + 41, + replayed_at, + absolute_expires_at, + ); + assert_eq!( + catalog + .refresh_account_session(replay) + .await + .expect("old-generation replay failed"), + AccountSessionRefreshResult::ReplayRevoked + ); + assert!(matches!( + catalog + .get_active_account_session(&newest_access, replayed_at) + .await, + Err(CatalogError::NotFound { .. }) + )); + + let mut connection = catalog + .pool() + .get() + .await + .expect("refresh history assertion connection failed"); + let history = account_session_refresh_tokens::table + .filter(account_session_refresh_tokens::session_id.eq(session_id)) + .order(account_session_refresh_tokens::generation.asc()) + .select(( + account_session_refresh_tokens::generation, + account_session_refresh_tokens::consumed_at, + )) + .load::<(i64, Option>)>(&mut connection) + .await + .expect("load refresh generation history failed"); + assert_eq!(history.len(), 3); + assert_eq!( + history.iter().map(|row| row.0).collect::>(), + [0, 1, 2] + ); + assert!(history[0].1.is_some()); + assert!(history[1].1.is_some()); + assert!(history[2].1.is_none()); +} + +/// Prove concurrent use of one refresh token yields one rotation and family revocation. +#[tokio::test] +#[ignore = "requires Docker"] +async fn concurrent_refresh_has_at_most_one_success_and_revokes_the_family() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "refresh-race"); + catalog + .create_account(account.clone()) + .await + .expect("create refresh-race account failed"); + let now = chrono::Utc::now(); + let issuance = make_session_issuance(account.id, AccountSessionClientKind::Cli, 51, now, None); + let session_id = issuance.session.id; + let presented = issuance.refresh_token_digest.clone(); + let persisted_session = catalog + .create_account_session(AccountSessionCreationRequest { + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionCreated, + account.id, + Some(session_id), + Some(AccountSessionClientKind::Cli), + now, + ), + issuance, + }) + .await + .expect("create refresh-race session failed"); + let absolute_expires_at = persisted_session.absolute_expires_at; + let rotated_at = persisted_session.created_at + chrono::Duration::minutes(1); + let first = make_refresh_request( + account.id, + session_id, + AccountSessionClientKind::Cli, + presented.clone(), + 61, + rotated_at, + absolute_expires_at, + ); + let second = make_refresh_request( + account.id, + session_id, + AccountSessionClientKind::Cli, + presented, + 71, + rotated_at, + absolute_expires_at, + ); + let (first_result, second_result) = tokio::join!( + catalog.refresh_account_session(first), + catalog.refresh_account_session(second), + ); + let outcomes = [ + first_result.expect("first concurrent refresh failed"), + second_result.expect("second concurrent refresh failed"), + ]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, AccountSessionRefreshResult::Rotated(_))) + .count(), + 1 + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, AccountSessionRefreshResult::ReplayRevoked)) + .count(), + 1 + ); + let mut connection = catalog + .pool() + .get() + .await + .expect("refresh-race assertion connection failed"); + let revoked_at = account_sessions::table + .find(session_id) + .select(account_sessions::revoked_at) + .first::>>(&mut connection) + .await + .expect("load refresh-race session revocation failed"); + assert_eq!(revoked_at, Some(rotated_at)); +} + +/// Prove pending MFA lookup enforces the exact owner, lifecycle, and expiry. +#[tokio::test] +#[ignore = "requires Docker"] +async fn pending_mfa_authenticator_lookup_is_exact_and_fail_closed() { + let (catalog, _container) = setup_catalog().await; + let owner = make_account(uuid::Uuid::new_v4(), "pending-mfa-owner"); + let other = make_account(uuid::Uuid::new_v4(), "pending-mfa-other"); + for account in [&owner, &other] { + catalog + .create_account(account.clone()) + .await + .expect("create pending-MFA account failed"); + } + let now = chrono::Utc::now(); + let authenticator_id = uuid::Uuid::new_v4(); + let pending_expires_at = now + chrono::Duration::minutes(5); + catalog + .begin_account_mfa_enrollment(AccountMfaEnrollmentRequest { + authenticator: AccountMfaAuthenticatorRecord { + id: authenticator_id, + account_id: owner.id, + state: AccountMfaAuthenticatorState::Pending, + secret: EncryptedTotpSecret { + ciphertext: vec![80; 48], + nonce: [81; 24], + key_version: 1, + }, + pending_expires_at: Some(pending_expires_at), + last_used_timestep: None, + created_at: now, + activated_at: None, + disabled_at: None, + }, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaEnrollmentStarted, + owner.id, + None, + None, + now, + ), + }) + .await + .expect("create pending authenticator lookup fixture failed"); + + let loaded = catalog + .get_pending_account_mfa_authenticator(owner.id, authenticator_id, now) + .await + .expect("load exact pending authenticator failed"); + assert_eq!(loaded.id, authenticator_id); + assert_eq!(loaded.account_id, owner.id); + assert_eq!(loaded.state, AccountMfaAuthenticatorState::Pending); + assert!(matches!( + catalog + .get_pending_account_mfa_authenticator(other.id, authenticator_id, now) + .await, + Err(CatalogError::NotFound { .. }) + )); + assert!(matches!( + catalog + .get_pending_account_mfa_authenticator(owner.id, authenticator_id, pending_expires_at,) + .await, + Err(CatalogError::NotFound { .. }) + )); + + let activated_at = now + chrono::Duration::seconds(1); + catalog + .activate_account_mfa(AccountMfaActivationRequest { + account_id: owner.id, + authenticator_id, + verified_timestep: 100, + recovery_codes: (0_u8..8) + .map(|offset| AccountMfaRecoveryCodeSeed { + id: uuid::Uuid::new_v4(), + code_digest: vec![90_u8.wrapping_add(offset); 32], + }) + .collect(), + activated_at, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaEnrollmentActivated, + owner.id, + None, + None, + activated_at, + ), + }) + .await + .expect("activate pending authenticator lookup fixture failed"); + assert!(matches!( + catalog + .get_pending_account_mfa_authenticator(owner.id, authenticator_id, activated_at) + .await, + Err(CatalogError::NotFound { .. }) + )); +} + +/// Prove TOTP timesteps and recovery codes are each consumed at most once. +#[tokio::test] +#[ignore = "requires Docker"] +async fn mfa_challenge_fences_totp_steps_recovery_codes_expiry_and_reuse() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "mfa-fencing"); + catalog + .create_account(account.clone()) + .await + .expect("create MFA-fencing account failed"); + let now = chrono::Utc::now(); + let (authenticator, recovery_codes) = enroll_test_mfa(&catalog, account.id, 81, now).await; + + let first_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Browser, + 91, + now + chrono::Duration::seconds(2), + ) + .await; + let first_completed_at = now + chrono::Duration::seconds(3); + let first_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Browser, + 92, + first_completed_at, + Some(first_completed_at), + ); + let first_session_id = first_issuance.session.id; + assert!(matches!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: first_challenge.clone(), + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::TotpTimestep(101), + issuance: first_issuance, + completed_at: first_completed_at, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(first_session_id), + Some(AccountSessionClientKind::Browser), + first_completed_at, + ), + }) + .await + .expect("complete first TOTP challenge failed"), + AccountMfaChallengeCompletionResult::Completed(_) + )); + + let reused_challenge_at = now + chrono::Duration::seconds(4); + let reused_challenge_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Browser, + 94, + reused_challenge_at, + Some(reused_challenge_at), + ); + assert_eq!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: first_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::TotpTimestep(102), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(reused_challenge_issuance.session.id), + Some(AccountSessionClientKind::Browser), + reused_challenge_at, + ), + issuance: reused_challenge_issuance, + completed_at: reused_challenge_at, + }) + .await + .expect("reused MFA challenge transaction failed"), + AccountMfaChallengeCompletionResult::Rejected + ); + + let step_replay_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Desktop, + 95, + now + chrono::Duration::seconds(5), + ) + .await; + let step_replay_at = now + chrono::Duration::seconds(6); + let step_replay_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 96, + step_replay_at, + Some(step_replay_at), + ); + assert_eq!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: step_replay_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::TotpTimestep(101), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(step_replay_issuance.session.id), + Some(AccountSessionClientKind::Desktop), + step_replay_at, + ), + issuance: step_replay_issuance, + completed_at: step_replay_at, + }) + .await + .expect("TOTP step replay transaction failed"), + AccountMfaChallengeCompletionResult::Rejected + ); + + let recovery_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Cli, + 97, + now + chrono::Duration::seconds(7), + ) + .await; + let recovery_at = now + chrono::Duration::seconds(8); + let recovery_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Cli, + 98, + recovery_at, + Some(recovery_at), + ); + assert!(matches!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: recovery_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::RecoveryCodeDigest( + recovery_codes[0].code_digest.clone(), + ), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(recovery_issuance.session.id), + Some(AccountSessionClientKind::Cli), + recovery_at, + ), + issuance: recovery_issuance, + completed_at: recovery_at, + }) + .await + .expect("MFA recovery-code completion failed"), + AccountMfaChallengeCompletionResult::Completed(_) + )); + + let recovery_replay_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Cli, + 99, + now + chrono::Duration::seconds(9), + ) + .await; + let recovery_replay_at = now + chrono::Duration::seconds(10); + let recovery_replay_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Cli, + 100, + recovery_replay_at, + Some(recovery_replay_at), + ); + assert_eq!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: recovery_replay_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::RecoveryCodeDigest( + recovery_codes[0].code_digest.clone(), + ), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(recovery_replay_issuance.session.id), + Some(AccountSessionClientKind::Cli), + recovery_replay_at, + ), + issuance: recovery_replay_issuance, + completed_at: recovery_replay_at, + }) + .await + .expect("MFA recovery-code replay transaction failed"), + AccountMfaChallengeCompletionResult::Rejected + ); + + let expired_created_at = now + chrono::Duration::seconds(11); + let expired_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Desktop, + 101, + expired_created_at, + ) + .await; + let expired_at = expired_created_at + chrono::Duration::minutes(6); + let expired_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 102, + expired_at, + Some(expired_at), + ); + assert_eq!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: expired_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::TotpTimestep(103), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(expired_issuance.session.id), + Some(AccountSessionClientKind::Desktop), + expired_at, + ), + issuance: expired_issuance, + completed_at: expired_at, + }) + .await + .expect("expired MFA challenge transaction failed"), + AccountMfaChallengeCompletionResult::Rejected + ); + + let mut connection = catalog + .pool() + .get() + .await + .expect("MFA fencing assertion connection failed"); + let last_used_timestep = account_mfa_authenticators::table + .filter(account_mfa_authenticators::account_id.eq(account.id)) + .filter(account_mfa_authenticators::state.eq("active")) + .select(account_mfa_authenticators::last_used_timestep) + .first::>(&mut connection) + .await + .expect("load active MFA timestep failed"); + assert_eq!(last_used_timestep, Some(101)); + let consumed_recovery_codes = account_mfa_recovery_codes::table + .filter(account_mfa_recovery_codes::consumed_at.is_not_null()) + .count() + .get_result::(&mut connection) + .await + .expect("count consumed MFA recovery codes failed"); + assert_eq!(consumed_recovery_codes, 1); + drop(connection); + + let replacement_challenge_at = now + chrono::Duration::seconds(20); + let replacement_challenge = create_test_mfa_challenge( + &catalog, + account.id, + AccountSessionClientKind::Browser, + 110, + replacement_challenge_at, + ) + .await; + enroll_test_mfa( + &catalog, + account.id, + 111, + replacement_challenge_at + chrono::Duration::seconds(1), + ) + .await; + let stale_authenticator_at = replacement_challenge_at + chrono::Duration::seconds(3); + let stale_authenticator_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Browser, + 130, + stale_authenticator_at, + Some(stale_authenticator_at), + ); + assert_eq!( + catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: replacement_challenge, + authenticator_id: authenticator.id, + proof: AccountMfaChallengeProof::TotpTimestep(104), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaChallengeCompleted, + account.id, + Some(stale_authenticator_issuance.session.id), + Some(AccountSessionClientKind::Browser), + stale_authenticator_at, + ), + issuance: stale_authenticator_issuance, + completed_at: stale_authenticator_at, + }) + .await + .expect("replaced-authenticator challenge transaction failed"), + AccountMfaChallengeCompletionResult::Rejected + ); +} + +/// Prove native authorization codes enforce exact binding, expiry, and one-time use. #[tokio::test] -#[ignore] -async fn password_rehash_is_compare_and_swap() { +#[ignore = "requires Docker"] +async fn native_authorization_codes_are_exact_expiring_and_single_use() { let (catalog, _container) = setup_catalog().await; - let account = make_account(uuid::Uuid::new_v4(), "password-rehash"); + let account = make_account(uuid::Uuid::new_v4(), "native-code"); catalog .create_account(account.clone()) .await - .expect("create password rehash account failed"); - let now = chrono::DateTime::from_timestamp_micros(chrono::Utc::now().timestamp_micros()) - .expect("current timestamp must fit"); - let old_hash = "$argon2id$v=19$m=19456,t=2,p=1$b2xk$cGFzcw".to_string(); - let new_hash = "$argon2id$v=19$m=65536,t=3,p=1$bmV3$cGFzcw".to_string(); + .expect("create native-code account failed"); + let now = chrono::Utc::now(); + let code_digest = vec![111; 32]; + let pkce_challenge = vec![112; 32]; + let redirect_uri = "http://127.0.0.1:43123/callback".to_string(); + catalog + .create_native_authorization_code(NativeAuthorizationCodeCreationRequest { + code: NativeAuthorizationCodeRecord { + id: uuid::Uuid::new_v4(), + account_id: account.id, + token_digest: code_digest.clone(), + client_kind: AccountSessionClientKind::Desktop, + redirect_uri: redirect_uri.clone(), + pkce_challenge: pkce_challenge.clone(), + mfa_verified_at: Some(now), + created_at: now, + expires_at: now + chrono::Duration::minutes(2), + consumed_at: None, + }, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeCreated, + account.id, + None, + Some(AccountSessionClientKind::Desktop), + now, + ), + }) + .await + .expect("create native authorization code failed"); + + let wrong_exchange_at = now + chrono::Duration::seconds(1); + let wrong_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 113, + wrong_exchange_at, + Some(now), + ); + assert_eq!( + catalog + .exchange_native_authorization_code(NativeAuthorizationCodeExchangeRequest { + code_token_digest: code_digest.clone(), + client_kind: AccountSessionClientKind::Desktop, + redirect_uri: "http://127.0.0.1:43124/callback".to_string(), + pkce_challenge: pkce_challenge.clone(), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + account.id, + Some(wrong_issuance.session.id), + Some(AccountSessionClientKind::Desktop), + wrong_exchange_at, + ), + issuance: wrong_issuance, + exchanged_at: wrong_exchange_at, + }) + .await + .expect("wrong native-code binding transaction failed"), + NativeAuthorizationCodeExchangeResult::Rejected + ); + + let exchanged_at = now + chrono::Duration::seconds(2); + let issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 115, + exchanged_at, + Some(now), + ); + let session_id = issuance.session.id; + assert!(matches!( + catalog + .exchange_native_authorization_code(NativeAuthorizationCodeExchangeRequest { + code_token_digest: code_digest.clone(), + client_kind: AccountSessionClientKind::Desktop, + redirect_uri: redirect_uri.clone(), + pkce_challenge: pkce_challenge.clone(), + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + account.id, + Some(session_id), + Some(AccountSessionClientKind::Desktop), + exchanged_at, + ), + issuance, + exchanged_at, + }) + .await + .expect("native authorization-code exchange failed"), + NativeAuthorizationCodeExchangeResult::Exchanged(_) + )); + + let reuse_at = now + chrono::Duration::seconds(3); + let reuse_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 117, + reuse_at, + Some(now), + ); + assert_eq!( + catalog + .exchange_native_authorization_code(NativeAuthorizationCodeExchangeRequest { + code_token_digest: code_digest, + client_kind: AccountSessionClientKind::Desktop, + redirect_uri, + pkce_challenge, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + account.id, + Some(reuse_issuance.session.id), + Some(AccountSessionClientKind::Desktop), + reuse_at, + ), + issuance: reuse_issuance, + exchanged_at: reuse_at, + }) + .await + .expect("native authorization-code reuse transaction failed"), + NativeAuthorizationCodeExchangeResult::Rejected + ); + + let expired_code_digest = vec![119; 32]; + let expired_pkce = vec![120; 32]; + catalog + .create_native_authorization_code(NativeAuthorizationCodeCreationRequest { + code: NativeAuthorizationCodeRecord { + id: uuid::Uuid::new_v4(), + account_id: account.id, + token_digest: expired_code_digest.clone(), + client_kind: AccountSessionClientKind::Cli, + redirect_uri: "http://[::1]:43125/callback".to_string(), + pkce_challenge: expired_pkce.clone(), + mfa_verified_at: None, + created_at: now, + expires_at: now + chrono::Duration::seconds(1), + consumed_at: None, + }, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeCreated, + account.id, + None, + Some(AccountSessionClientKind::Cli), + now, + ), + }) + .await + .expect("create expired native-code fixture failed"); + let expired_at = now + chrono::Duration::seconds(2); + let expired_issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Cli, + 121, + expired_at, + None, + ); + assert_eq!( + catalog + .exchange_native_authorization_code(NativeAuthorizationCodeExchangeRequest { + code_token_digest: expired_code_digest, + client_kind: AccountSessionClientKind::Cli, + redirect_uri: "http://[::1]:43125/callback".to_string(), + pkce_challenge: expired_pkce, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + account.id, + Some(expired_issuance.session.id), + Some(AccountSessionClientKind::Cli), + expired_at, + ), + issuance: expired_issuance, + exchanged_at: expired_at, + }) + .await + .expect("expired native-code exchange transaction failed"), + NativeAuthorizationCodeExchangeResult::Rejected + ); +} + +/// Prove privileged role grants and MFA disable both fail closed around assurance. +#[tokio::test] +#[ignore = "requires Docker"] +async fn privileged_roles_require_and_retain_active_mfa() { + let (catalog, _container) = setup_catalog().await; + let administrator = make_account(uuid::Uuid::new_v4(), "mfa-role-admin"); + let target = make_account(uuid::Uuid::new_v4(), "mfa-role-target"); + catalog + .create_account(administrator.clone()) + .await + .expect("create MFA-role administrator failed"); + catalog + .create_account(target.clone()) + .await + .expect("create MFA-role target failed"); + assign_test_platform_role(&catalog, administrator.id, "administrator").await; + + let without_mfa = catalog + .assign_account_platform_role(PlatformRoleAssignmentRequest { + account_id: target.id, + role: PlatformRole::Moderator, + actor_account_id: administrator.id, + }) + .await; + assert!(matches!(without_mfa, Err(CatalogError::Validation(_)))); + + let now = chrono::Utc::now(); + enroll_test_mfa(&catalog, target.id, 131, now).await; + let assigned = catalog + .assign_account_platform_role(PlatformRoleAssignmentRequest { + account_id: target.id, + role: PlatformRole::Moderator, + actor_account_id: administrator.id, + }) + .await + .expect("assign MFA-protected moderator failed"); + assert_eq!(assigned.state, PlatformRoleState::Active); + let disable_at = now + chrono::Duration::minutes(1); + let disable_result = catalog + .disable_account_mfa(AccountMfaDisableRequest { + account_id: target.id, + disabled_at: disable_at, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaDisabled, + target.id, + None, + None, + disable_at, + ), + }) + .await; + assert!(matches!(disable_result, Err(CatalogError::Validation(_)))); +} + +/// Prove a concurrent role grant and MFA disable cannot commit an unassured role. +#[tokio::test] +#[ignore = "requires Docker"] +async fn concurrent_role_grant_and_mfa_disable_preserve_assurance() { + let (catalog, _container) = setup_catalog().await; + let administrator = make_account(uuid::Uuid::new_v4(), "mfa-race-admin"); + let target = make_account(uuid::Uuid::new_v4(), "mfa-race-target"); + for account in [&administrator, &target] { + catalog + .create_account(account.clone()) + .await + .expect("create MFA-race account failed"); + } + assign_test_platform_role(&catalog, administrator.id, "administrator").await; + + let now = chrono::Utc::now(); + enroll_test_mfa(&catalog, target.id, 133, now).await; let mut connection = catalog .pool() .get() .await - .expect("password rehash insert connection failed"); - diesel::insert_into(account_password_credentials::table) - .values(( - account_password_credentials::account_id.eq(account.id), - account_password_credentials::normalized_email.eq("password-rehash@example.test"), - account_password_credentials::password_hash.eq(old_hash.clone()), - account_password_credentials::password_version.eq(1_i16), - account_password_credentials::pepper_version.eq(1_i16), - account_password_credentials::email_verified_at.eq(Some(now)), - account_password_credentials::created_at.eq(now), - account_password_credentials::password_changed_at.eq(now), - account_password_credentials::updated_at.eq(now), - )) - .execute(&mut connection) + .expect("MFA-race trigger connection failed"); + connection + .batch_execute( + "CREATE FUNCTION delay_mfa_race_role_insert() RETURNS trigger \ + LANGUAGE plpgsql AS $$ \ + BEGIN \ + PERFORM pg_sleep(0.5); \ + RETURN NEW; \ + END \ + $$; \ + CREATE TRIGGER delay_mfa_race_role_insert \ + BEFORE INSERT ON account_platform_roles \ + FOR EACH ROW EXECUTE FUNCTION delay_mfa_race_role_insert();", + ) .await - .expect("insert password rehash credential failed"); + .expect("install MFA-race trigger failed"); drop(connection); - let updated_at = now + chrono::Duration::seconds(1); - let request = AccountPasswordRehashRequest { - account_id: account.id, - normalized_email: "password-rehash@example.test".to_string(), - expected_password_hash: old_hash, - expected_password_version: 1, - expected_pepper_version: 1, - expected_updated_at: now, - new_password_hash: new_hash.clone(), - new_password_version: 1, - new_pepper_version: 2, - updated_at, - }; - assert!(catalog - .rehash_account_password_credential(request.clone()) + let assignment_catalog = catalog.clone(); + let assignment = tokio::spawn(async move { + assignment_catalog + .assign_account_platform_role(PlatformRoleAssignmentRequest { + account_id: target.id, + role: PlatformRole::Moderator, + actor_account_id: administrator.id, + }) + .await + }); + tokio::time::sleep(Duration::from_millis(100)).await; + let disable_at = now + chrono::Duration::minutes(1); + let disable_result = catalog + .disable_account_mfa(AccountMfaDisableRequest { + account_id: target.id, + disabled_at: disable_at, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::MfaDisabled, + target.id, + None, + None, + disable_at, + ), + }) + .await; + let assignment_result = assignment.await.expect("role assignment task panicked"); + + assert_eq!( + assignment_result + .expect("serialized role assignment must retain MFA") + .state, + PlatformRoleState::Active + ); + assert!(matches!(disable_result, Err(CatalogError::Validation(_)))); + catalog + .get_active_account_mfa_authenticator(target.id) .await - .expect("first password rehash failed")); - assert!( - !catalog - .rehash_account_password_credential(request) + .expect("active privileged role must retain active MFA"); +} + +/// Prove suspension revokes session families and blocks every later issuance path. +#[tokio::test] +#[ignore = "requires Docker"] +async fn inactive_accounts_cannot_retain_refresh_or_create_sessions() { + let (catalog, _container) = setup_catalog().await; + let administrator = make_account(uuid::Uuid::new_v4(), "session-status-admin"); + let target = make_account(uuid::Uuid::new_v4(), "session-status-target"); + for account in [&administrator, &target] { + catalog + .create_account(account.clone()) .await - .expect("stale password rehash failed"), - "a stale rehash must not overwrite the upgraded credential" + .expect("create session-status account failed"); + } + assign_test_platform_role(&catalog, administrator.id, "administrator").await; + + let now = chrono::Utc::now(); + let issuance = + make_session_issuance(target.id, AccountSessionClientKind::Desktop, 135, now, None); + let access_digest = issuance.session.token_digest.clone(); + let refresh_digest = issuance.refresh_token_digest.clone(); + let session_id = issuance.session.id; + let persisted_session = catalog + .create_account_session(AccountSessionCreationRequest { + issuance, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionCreated, + target.id, + Some(session_id), + Some(AccountSessionClientKind::Desktop), + now, + ), + }) + .await + .expect("create pre-suspension session failed"); + catalog + .get_active_account_session(&access_digest, persisted_session.created_at) + .await + .expect("pre-suspension session must be active"); + + catalog + .set_account_status(AccountStatusChangeRequest { + account_id: target.id, + status: AccountStatus::Suspended, + actor_account_id: administrator.id, + }) + .await + .expect("suspend session-status target failed"); + assert!(matches!( + catalog + .get_active_account_session(&access_digest, persisted_session.created_at) + .await, + Err(CatalogError::NotFound { .. }) + )); + + let rotated_at = persisted_session.created_at + chrono::Duration::minutes(1); + assert_eq!( + catalog + .refresh_account_session(make_refresh_request( + target.id, + session_id, + AccountSessionClientKind::Desktop, + refresh_digest, + 137, + rotated_at, + persisted_session.absolute_expires_at, + )) + .await + .expect("suspended-account refresh transaction failed"), + AccountSessionRefreshResult::Rejected ); - let credential = catalog - .get_account_password_credential("password-rehash@example.test") + let replacement = make_session_issuance( + target.id, + AccountSessionClientKind::Cli, + 139, + rotated_at, + None, + ); + let replacement_session_id = replacement.session.id; + assert!(matches!( + catalog + .create_account_session(AccountSessionCreationRequest { + issuance: replacement, + audit_event: make_auth_success_audit( + AccountAuthAuditEventKind::SessionCreated, + target.id, + Some(replacement_session_id), + Some(AccountSessionClientKind::Cli), + rotated_at, + ), + }) + .await, + Err(CatalogError::Unauthorized { .. }) + )); + + let mut connection = catalog + .pool() + .get() .await - .expect("load upgraded password credential failed"); - assert_eq!(credential.password_hash, new_hash); - assert_eq!(credential.pepper_version, 2); - assert_eq!(credential.password_changed_at, now); - assert_eq!(credential.updated_at, updated_at); + .expect("session-status assertion connection failed"); + let revoked_at = account_sessions::table + .find(session_id) + .select(account_sessions::revoked_at) + .first::>>(&mut connection) + .await + .expect("load suspended session revocation failed"); + assert!(revoked_at.is_some()); +} + +/// Prove a duplicate success-audit identifier rolls back session and refresh state. +#[tokio::test] +#[ignore = "requires Docker"] +async fn duplicate_auth_audit_id_rolls_back_security_state() { + let (catalog, _container) = setup_catalog().await; + let account = make_account(uuid::Uuid::new_v4(), "audit-rollback"); + catalog + .create_account(account.clone()) + .await + .expect("create audit-rollback account failed"); + let now = chrono::Utc::now(); + let duplicate_id = uuid::Uuid::new_v4(); + catalog + .append_account_auth_audit_event(AccountAuthAuditEventRecord { + id: duplicate_id, + event_kind: AccountAuthAuditEventKind::AuthenticationRejected, + outcome: AccountAuthAuditOutcome::Rejected, + account_id: Some(account.id), + session_id: None, + client_kind: Some(AccountSessionClientKind::Desktop), + identifier_tag: Some(vec![141; 32]), + network_tag: Some(vec![142; 32]), + reason_code: Some("invalid_credentials".to_string()), + created_at: now, + }) + .await + .expect("seed duplicate authentication audit id failed"); + let issuance = make_session_issuance( + account.id, + AccountSessionClientKind::Desktop, + 143, + now, + None, + ); + let session_id = issuance.session.id; + let mut duplicate_success = make_auth_success_audit( + AccountAuthAuditEventKind::SessionCreated, + account.id, + Some(session_id), + Some(AccountSessionClientKind::Desktop), + now, + ); + duplicate_success.id = duplicate_id; + assert!(catalog + .create_account_session(AccountSessionCreationRequest { + issuance, + audit_event: duplicate_success, + }) + .await + .is_err()); + + let mut connection = catalog + .pool() + .get() + .await + .expect("audit rollback assertion connection failed"); + let session_count = account_sessions::table + .filter(account_sessions::id.eq(session_id)) + .count() + .get_result::(&mut connection) + .await + .expect("count rolled-back sessions failed"); + let refresh_count = account_session_refresh_tokens::table + .filter(account_session_refresh_tokens::session_id.eq(session_id)) + .count() + .get_result::(&mut connection) + .await + .expect("count rolled-back refresh generations failed"); + assert_eq!((session_count, refresh_count), (0, 0)); +} + +/// Prove the authentication hardening migration refuses destructive rollback. +#[tokio::test] +#[ignore = "requires Docker"] +async fn account_auth_hardening_migration_is_forward_only() { + let (catalog, _container) = setup_catalog().await; + let mut connection = catalog + .pool() + .get() + .await + .expect("auth migration rollback connection failed"); + let error = connection + .batch_execute(include_str!( + "../migrations/2026-08-02-000001_harden_account_auth/down.sql" + )) + .await + .expect_err("authentication hardening rollback must be refused"); + assert!(error.to_string().contains("forward-only")); + let retained_table_count = account_auth_audit_events::table + .count() + .get_result::(&mut connection) + .await + .expect("authentication audit table must remain after refused rollback"); + assert_eq!(retained_table_count, 0); } /// Create one approved publisher with an active deterministic signing key. @@ -4716,6 +6726,7 @@ async fn platform_role_administration_is_authorized_idempotent_and_auditable() { .expect_err("granting to a missing account must fail"); assert!(matches!(missing, CatalogError::NotFound { .. })); + enroll_test_mfa(&catalog, target.id, 151, chrono::Utc::now()).await; let granted = catalog .assign_account_platform_role(PlatformRoleAssignmentRequest { account_id: target.id, @@ -4821,6 +6832,7 @@ async fn last_administrator_authority_is_protected() { assert!(matches!(suspend_error, CatalogError::Validation(_))); // A second administrator provides coverage. + enroll_test_mfa(&catalog, second.id, 152, chrono::Utc::now()).await; catalog .assign_account_platform_role(PlatformRoleAssignmentRequest { account_id: second.id, @@ -4868,6 +6880,7 @@ async fn concurrent_administrator_revocations_preserve_coverage() { .expect("create race fixture account failed"); } assign_test_platform_role(&catalog, first.id, "administrator").await; + enroll_test_mfa(&catalog, second.id, 153, chrono::Utc::now()).await; catalog .assign_account_platform_role(PlatformRoleAssignmentRequest { account_id: second.id, @@ -4984,6 +6997,7 @@ async fn cold_deployment_drill_reaches_a_public_pack() { assign_test_platform_role(&catalog, promoter.id, "administrator").await; // Stage 4: from here on, only the shipped administrator API is used. + enroll_test_mfa(&catalog, would_be_reviewer.id, 154, chrono::Utc::now()).await; let granted = catalog .assign_account_platform_role(PlatformRoleAssignmentRequest { account_id: would_be_reviewer.id, @@ -5110,11 +7124,22 @@ async fn concurrent_invite_redemption_creates_exactly_one_local_account() { catalog.register_local_account(first), catalog.register_local_account(second), ); - let failed = match (first_result, second_result) { - (Ok(_), Err(error)) | (Err(error), Ok(_)) => error, + let (successful, failed) = match (first_result, second_result) { + (Ok(result), Err(error)) | (Err(error), Ok(result)) => (result, error), results => panic!("exactly one concurrent redemption may succeed, got results={results:?}"), }; assert!(matches!(failed, CatalogError::Unauthorized { .. })); + let persisted_session = catalog + .get_active_account_session( + &successful.session.token_digest, + successful.session.created_at, + ) + .await + .expect("load persisted registration session failed"); + assert_eq!( + successful.session, persisted_session, + "registration must return PostgreSQL-normalized session timestamps" + ); let mut connection = catalog .pool() diff --git a/crates/frameshift-catalog/src/backend.rs b/crates/frameshift-catalog/src/backend.rs index ccc35bd..090f49a 100644 --- a/crates/frameshift-catalog/src/backend.rs +++ b/crates/frameshift-catalog/src/backend.rs @@ -13,15 +13,23 @@ use crate::error::{CatalogError, HealthStatus}; use crate::filters::{PackSearchFilters, PackSearchResult}; use crate::identity::Ed25519PublicKey; use crate::records::{ - AccountInviteIssueRequest, AccountInviteRecord, AccountInviteRequestRecord, - AccountInviteReviewRequest, AccountInviteStatus, AccountPasswordCredentialRecord, - AccountPasswordRehashRequest, AccountRecord, AccountSessionRecord, AccountStatusChangeRequest, - AuthorRecord, LocalAccountRegistrationRequest, LocalAccountRegistrationResult, PackRecord, - PackVersionRecord, PlatformRoleAssignmentRequest, PlatformRoleRecord, - PlatformRoleRevocationRequest, PublicationAppealCaseRecord, PublicationAppealCursor, - PublicationAppealRecord, PublicationAppealRequest, PublicationAppealResolutionRecord, - PublicationAppealResolutionRequest, PublicationIntentClaim, PublicationIntentRecord, - PublicationLifecycleCursor, PublicationLifecycleDecisionRecord, + AccountAuthAuditEventRecord, AccountInviteIssueRequest, AccountInviteRecord, + AccountInviteRequestRecord, AccountInviteReviewRequest, AccountInviteStatus, + AccountMfaActivationRequest, AccountMfaAuthenticatorRecord, + AccountMfaChallengeCompletionRequest, AccountMfaChallengeCompletionResult, + AccountMfaChallengeCreationRequest, AccountMfaDisableRequest, AccountMfaEnrollmentRequest, + AccountPasswordCredentialRecord, AccountPasswordRehashRequest, AccountRecord, + AccountSessionCreationRequest, AccountSessionRecord, AccountSessionRefreshRequest, + AccountSessionRefreshResult, AccountStatusChangeRequest, AuthorRecord, + LocalAccountRegistrationRequest, LocalAccountRegistrationResult, + NativeAuthorizationCodeCreationRequest, NativeAuthorizationCodeExchangeRequest, + NativeAuthorizationCodeExchangeResult, PackRecord, PackVersionRecord, + PasswordRecoveryCompletionRequest, PasswordRecoveryDeliveryClaimRequest, + PasswordRecoveryDeliveryRecord, PasswordRecoveryEnqueueRequest, PlatformRoleAssignmentRequest, + PlatformRoleRecord, PlatformRoleRevocationRequest, PublicationAppealCaseRecord, + PublicationAppealCursor, PublicationAppealRecord, PublicationAppealRequest, + PublicationAppealResolutionRecord, PublicationAppealResolutionRequest, PublicationIntentClaim, + PublicationIntentRecord, PublicationLifecycleCursor, PublicationLifecycleDecisionRecord, PublicationModerationDecisionRecord, PublicationModerationDecisionRequest, PublicationModerationSnapshot, PublicationPromotionRecord, PublicationPromotionRequest, PublicationSubmissionRecord, PublicationSubmissionRequest, PublicationTombstoneRequest, @@ -179,14 +187,97 @@ pub trait CatalogBackend: Send + Sync { }) } - /// Create a revocable session after successful first-party authentication. + /// Atomically create a digest-only reset token and encrypted delivery. + /// + /// Returns `true` only when an active, verified local account was eligible + /// and outside the configured cooldown. A `false` result deliberately + /// collapses absent, inactive, unverified, and cooling-down identities. + async fn enqueue_account_password_recovery( + &self, + request: PasswordRecoveryEnqueueRequest, + ) -> Result { + let _ = request; + Err(CatalogError::Validation( + "password recovery is not supported by this backend".to_string(), + )) + } + + /// Atomically consume one active token, replace its credential, revoke all + /// account sessions, and enqueue an encrypted password-changed notice. + /// + /// Returns `false` for every missing, expired, consumed, revoked, inactive, + /// or otherwise ineligible token so callers can render one public error. + async fn complete_account_password_recovery( + &self, + request: PasswordRecoveryCompletionRequest, + ) -> Result { + let _ = request; + Err(CatalogError::Validation( + "password recovery is not supported by this backend".to_string(), + )) + } + + /// Lease a bounded batch of ready encrypted deliveries under one claim UUID. + async fn claim_password_recovery_deliveries( + &self, + request: PasswordRecoveryDeliveryClaimRequest, + ) -> Result, CatalogError> { + let _ = request; + Err(CatalogError::Validation( + "password recovery delivery is not supported by this backend".to_string(), + )) + } + + /// Acknowledge one successful delivery only for its current fenced claim. + async fn mark_password_recovery_delivery_sent( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + sent_at: DateTime, + provider_message_id: String, + ) -> Result { + let _ = (delivery_id, claim_id, sent_at, provider_message_id); + Err(CatalogError::Validation( + "password recovery delivery is not supported by this backend".to_string(), + )) + } + + /// Release one fenced claim for a later retry with a sanitized error code. + async fn retry_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + next_attempt_at: DateTime, + last_error_code: String, + ) -> Result { + let _ = (delivery_id, claim_id, next_attempt_at, last_error_code); + Err(CatalogError::Validation( + "password recovery delivery is not supported by this backend".to_string(), + )) + } + + /// Permanently fail one delivery only for its current fenced claim. + async fn fail_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + failed_at: DateTime, + last_error_code: String, + ) -> Result { + let _ = (delivery_id, claim_id, failed_at, last_error_code); + Err(CatalogError::Validation( + "password recovery delivery is not supported by this backend".to_string(), + )) + } + + /// Create an access-token session and refresh generation atomically. async fn create_account_session( &self, - record: AccountSessionRecord, - ) -> Result<(), CatalogError> { + request: AccountSessionCreationRequest, + ) -> Result { Err(CatalogError::Unauthorized { kind: "account_session", - key: record.id.to_string(), + key: request.issuance.session.id.to_string(), }) } @@ -231,6 +322,136 @@ pub trait CatalogBackend: Send + Sync { }) } + /// Atomically rotate one refresh generation or revoke its family on replay. + async fn refresh_account_session( + &self, + request: AccountSessionRefreshRequest, + ) -> Result { + let _ = request; + Err(CatalogError::Validation( + "refresh-token sessions are not supported by this backend".to_string(), + )) + } + + /// Retrieve the active encrypted TOTP metadata for one account. + async fn get_active_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + ) -> Result { + Err(CatalogError::NotFound { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }) + } + + /// Retrieve one unexpired pending authenticator owned by the account. + async fn get_pending_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + authenticator_id: uuid::Uuid, + now: DateTime, + ) -> Result { + let _ = (account_id, now); + Err(CatalogError::NotFound { + kind: "account_mfa_authenticator", + key: authenticator_id.to_string(), + }) + } + + /// Atomically replace any pending enrollment with encrypted TOTP metadata. + async fn begin_account_mfa_enrollment( + &self, + request: AccountMfaEnrollmentRequest, + ) -> Result { + let account_id = request.authenticator.account_id; + let _ = request; + Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }) + } + + /// Atomically activate a pending authenticator and replace recovery codes. + async fn activate_account_mfa( + &self, + request: AccountMfaActivationRequest, + ) -> Result { + let account_id = request.account_id; + let _ = request; + Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }) + } + + /// Disable active MFA only when the account has no privileged role. + async fn disable_account_mfa( + &self, + request: AccountMfaDisableRequest, + ) -> Result { + let account_id = request.account_id; + let _ = request; + Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }) + } + + /// Atomically create one digest-only expiring MFA login challenge. + async fn create_account_mfa_challenge( + &self, + request: AccountMfaChallengeCreationRequest, + ) -> Result<(), CatalogError> { + let _ = request; + Err(CatalogError::Validation( + "MFA login challenges are not supported by this backend".to_string(), + )) + } + + /// Consume an MFA challenge and proof while issuing a verified session. + async fn complete_account_mfa_challenge( + &self, + request: AccountMfaChallengeCompletionRequest, + ) -> Result { + let _ = request; + Err(CatalogError::Validation( + "MFA login challenges are not supported by this backend".to_string(), + )) + } + + /// Atomically create one digest-only native authorization code. + async fn create_native_authorization_code( + &self, + request: NativeAuthorizationCodeCreationRequest, + ) -> Result<(), CatalogError> { + let _ = request; + Err(CatalogError::Validation( + "native authorization codes are not supported by this backend".to_string(), + )) + } + + /// Consume one exactly bound native authorization code and issue a session. + async fn exchange_native_authorization_code( + &self, + request: NativeAuthorizationCodeExchangeRequest, + ) -> Result { + let _ = request; + Err(CatalogError::Validation( + "native authorization codes are not supported by this backend".to_string(), + )) + } + + /// Append one sanitized authentication event outside a success transaction. + async fn append_account_auth_audit_event( + &self, + event: AccountAuthAuditEventRecord, + ) -> Result<(), CatalogError> { + let _ = event; + Err(CatalogError::Validation( + "authentication audit events are not supported by this backend".to_string(), + )) + } + /// Create an OIDC-backed account with a unique `(issuer, subject)` identity. /// /// # Errors diff --git a/crates/frameshift-catalog/src/lib.rs b/crates/frameshift-catalog/src/lib.rs index 34968e8..1c8174f 100644 --- a/crates/frameshift-catalog/src/lib.rs +++ b/crates/frameshift-catalog/src/lib.rs @@ -61,23 +61,34 @@ pub use filters::{PackSearchFilters, PackSearchResult, SortMode}; pub use frameshift_pack::ObjectHash; pub use identity::Ed25519PublicKey; pub use records::{ + AccountAuthAuditEventKind, AccountAuthAuditEventRecord, AccountAuthAuditOutcome, AccountInviteIntent, AccountInviteIssueRequest, AccountInviteRecord, AccountInviteRequestRecord, AccountInviteReviewRequest, AccountInviteStatus, + AccountMfaActivationRequest, AccountMfaAuthenticatorRecord, AccountMfaAuthenticatorState, + AccountMfaChallengeCompletionRequest, AccountMfaChallengeCompletionResult, + AccountMfaChallengeCreationRequest, AccountMfaChallengeProof, AccountMfaDisableRequest, + AccountMfaEnrollmentRequest, AccountMfaLoginChallengeRecord, AccountMfaRecoveryCodeSeed, AccountPasswordCredentialRecord, AccountPasswordRehashRequest, AccountRecord, - AccountSessionClientKind, AccountSessionRecord, AccountStatus, AccountStatusChangeRequest, - AuthorRecord, LocalAccountRegistrationRequest, LocalAccountRegistrationResult, MembershipState, - OauthLink, PackRecord, PackVersionRecord, PlatformRole, PlatformRoleAssignmentRequest, - PlatformRoleRecord, PlatformRoleRevocationRequest, PlatformRoleState, - PublicationAppealCaseRecord, PublicationAppealCursor, PublicationAppealDisposition, - PublicationAppealRecord, PublicationAppealRequest, PublicationAppealResolutionRecord, - PublicationAppealResolutionRequest, PublicationIntentClaim, PublicationIntentRecord, - PublicationLifecycleAction, PublicationLifecycleCursor, PublicationLifecycleDecisionRecord, - PublicationModerationAction, PublicationModerationDecisionRecord, - PublicationModerationDecisionRequest, PublicationModerationSnapshot, - PublicationPromotionRecord, PublicationPromotionRequest, PublicationSubmissionRecord, - PublicationSubmissionRequest, PublicationSubmissionState, PublicationTombstoneRequest, - PublicationWithdrawalRequest, PublisherAuditEventRecord, PublisherKeyRecord, PublisherKeyState, - PublisherMembershipRecord, PublisherModerationStatus, PublisherProfileRecord, PublisherRole, - PublisherSuspensionRequest, + AccountSessionClientKind, AccountSessionCreationRequest, AccountSessionIssuance, + AccountSessionRecord, AccountSessionRefreshRequest, AccountSessionRefreshResult, AccountStatus, + AccountStatusChangeRequest, AuthorRecord, EncryptedPasswordRecoveryDelivery, + EncryptedTotpSecret, LocalAccountRegistrationRequest, LocalAccountRegistrationResult, + MembershipState, NativeAuthorizationCodeCreationRequest, + NativeAuthorizationCodeExchangeRequest, NativeAuthorizationCodeExchangeResult, + NativeAuthorizationCodeRecord, OauthLink, PackRecord, PackVersionRecord, + PasswordRecoveryCompletionRequest, PasswordRecoveryDeliveryClaimRequest, + PasswordRecoveryDeliveryKind, PasswordRecoveryDeliveryRecord, PasswordRecoveryEnqueueRequest, + PlatformRole, PlatformRoleAssignmentRequest, PlatformRoleRecord, PlatformRoleRevocationRequest, + PlatformRoleState, PublicationAppealCaseRecord, PublicationAppealCursor, + PublicationAppealDisposition, PublicationAppealRecord, PublicationAppealRequest, + PublicationAppealResolutionRecord, PublicationAppealResolutionRequest, PublicationIntentClaim, + PublicationIntentRecord, PublicationLifecycleAction, PublicationLifecycleCursor, + PublicationLifecycleDecisionRecord, PublicationModerationAction, + PublicationModerationDecisionRecord, PublicationModerationDecisionRequest, + PublicationModerationSnapshot, PublicationPromotionRecord, PublicationPromotionRequest, + PublicationSubmissionRecord, PublicationSubmissionRequest, PublicationSubmissionState, + PublicationTombstoneRequest, PublicationWithdrawalRequest, PublisherAuditEventRecord, + PublisherKeyRecord, PublisherKeyState, PublisherMembershipRecord, PublisherModerationStatus, + PublisherProfileRecord, PublisherRole, PublisherSuspensionRequest, }; pub use status::{PackStatus, TombstoneReason, TombstoneRecord}; diff --git a/crates/frameshift-catalog/src/records.rs b/crates/frameshift-catalog/src/records.rs index f6917cc..a04047e 100644 --- a/crates/frameshift-catalog/src/records.rs +++ b/crates/frameshift-catalog/src/records.rs @@ -204,6 +204,130 @@ pub struct AccountPasswordRehashRequest { pub updated_at: DateTime, } +/// Stable purpose attached to one encrypted password-recovery delivery. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PasswordRecoveryDeliveryKind { + /// A message carrying the single-use password-reset link. + Reset, + /// A notification confirming that the account password changed. + PasswordChanged, +} + +/// Caller-encrypted payload and immutable metadata for one recovery delivery. +/// +/// The catalog never receives the plaintext payload or encryption key. The +/// caller binds `id`, the operation-specific delivery kind, and `key_version` +/// into its authenticated encryption associated data before constructing this +/// envelope. +#[derive(Clone, PartialEq, Eq)] +pub struct EncryptedPasswordRecoveryDelivery { + /// Stable outbox identifier and provider idempotency key. + pub id: Uuid, + /// Opaque authenticated ciphertext containing the delivery payload. + pub ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub nonce: [u8; 24], + /// Deployment-managed encryption-key version used for this ciphertext. + pub key_version: i16, + /// Exclusive deadline after which a worker must not send this payload. + pub expires_at: DateTime, +} + +/// Atomic input for creating a password-reset token and encrypted delivery. +#[derive(Clone, PartialEq, Eq)] +pub struct PasswordRecoveryEnqueueRequest { + /// Stable internal identifier for the digest-only reset-token row. + pub token_id: Uuid, + /// Lowercase, trimmed email used for an indistinguishable account lookup. + pub normalized_email: String, + /// SHA-256 digest of the raw reset token retained only by the requester. + pub token_digest: Vec, + /// UTC timestamp at which the request was admitted. + pub requested_at: DateTime, + /// Exclusive deadline for consuming the reset token. + pub token_expires_at: DateTime, + /// Requests newer than this timestamp suppress this enqueue operation. + pub cooldown_cutoff: DateTime, + /// Encrypted reset-link delivery stored in the same transaction. + pub delivery: EncryptedPasswordRecoveryDelivery, +} + +/// Atomic input for consuming a reset token and replacing its credential. +#[derive(Clone, PartialEq, Eq)] +pub struct PasswordRecoveryCompletionRequest { + /// SHA-256 digest of the reset token presented by the caller. + pub token_digest: Vec, + /// Fresh Argon2id PHC string produced under the current password policy. + pub new_password_hash: String, + /// Application credential-record version for the fresh hash. + pub new_password_version: i16, + /// Deployment-pepper version used to produce the fresh hash. + pub new_pepper_version: i16, + /// UTC timestamp committed as the password-change and revocation time. + pub completed_at: DateTime, + /// Encrypted password-changed notice stored in the same transaction. + pub delivery: EncryptedPasswordRecoveryDelivery, +} + +/// One encrypted password-recovery delivery and its durable worker lifecycle. +/// +/// This record deliberately contains no plaintext message body, reset URL, or +/// bearer token. `claim_id` fences worker acknowledgements and is present only +/// while the row has an active lease. +#[derive(Clone, PartialEq, Eq)] +pub struct PasswordRecoveryDeliveryRecord { + /// Stable outbox identifier and provider idempotency key. + pub id: Uuid, + /// Account receiving the recovery-related message. + pub account_id: Uuid, + /// Stable message purpose used in authenticated encryption metadata. + pub kind: PasswordRecoveryDeliveryKind, + /// Lowercase, trimmed destination email derived from the credential row. + pub recipient: String, + /// Opaque authenticated ciphertext containing the message payload. + pub ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub nonce: [u8; 24], + /// Deployment-managed encryption-key version required to decrypt the payload. + pub key_version: i16, + /// Number of leases issued for this row, including stale reclaims. + pub attempt_count: u32, + /// Most recent time a worker acquired this row, retained after lease release. + pub last_attempt_at: Option>, + /// UUID fencing the currently active worker lease. + pub claim_id: Option, + /// Time at which the currently active worker lease began. + pub claimed_at: Option>, + /// Earliest time at which an unclaimed worker may acquire this row. + pub next_attempt_at: DateTime, + /// Exclusive deadline after which no worker may acquire this row. + pub expires_at: DateTime, + /// Time at which a fenced worker acknowledged successful delivery. + pub sent_at: Option>, + /// Provider-assigned message identifier retained after successful delivery. + pub provider_message_id: Option, + /// Time at which a fenced worker marked the delivery permanently failed. + pub failed_at: Option>, + /// Bounded static diagnostic code from the most recent failed attempt. + pub last_error_code: Option, + /// UTC timestamp at which the transaction created this row. + pub created_at: DateTime, +} + +/// Bounded request for leasing pending recovery deliveries to one worker. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PasswordRecoveryDeliveryClaimRequest { + /// UUID shared by every delivery leased in this batch. + pub claim_id: Uuid, + /// UTC timestamp recorded for this lease attempt. + pub claimed_at: DateTime, + /// Existing claims at or before this timestamp may be reclaimed as stale. + pub stale_before: DateTime, + /// Maximum number of rows to lease in this batch. + pub limit: u32, +} + /// Client class receiving a first-party account session. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] @@ -216,14 +340,77 @@ pub enum AccountSessionClientKind { Cli, } -/// Revocable first-party session storing only an opaque token digest. +/// Stable kind for one sanitized first-party authentication audit event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountAuthAuditEventKind { + /// A password, registration, MFA, or authorization-code flow issued a session. + SessionCreated, + /// A valid refresh credential rotated an existing session family. + SessionRefreshed, + /// A consumed refresh credential was replayed and revoked its family. + SessionReplayRevoked, + /// An encrypted TOTP authenticator entered pending enrollment. + MfaEnrollmentStarted, + /// A verified pending authenticator replaced the prior active authenticator. + MfaEnrollmentActivated, + /// An unprivileged account disabled its active authenticator. + MfaDisabled, + /// Password verification created an expiring second-factor challenge. + MfaChallengeCreated, + /// A TOTP timestep or recovery code completed a second-factor challenge. + MfaChallengeCompleted, + /// An authenticated browser issued a one-time native authorization code. + NativeAuthorizationCodeCreated, + /// A native client exchanged a bound authorization code using S256 PKCE. + NativeAuthorizationCodeConsumed, + /// An authentication attempt was rejected without exposing sensitive input. + AuthenticationRejected, +} + +/// Stable outcome for a sanitized authentication audit event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountAuthAuditOutcome { + /// The audited state transition committed successfully. + Success, + /// The audited authentication attempt was rejected. + Rejected, +} + +/// Append-only authentication audit event containing only sanitized fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountAuthAuditEventRecord { + /// Stable event identifier used to make atomic writes externally traceable. + pub id: Uuid, + /// Stable event class that never contains caller-controlled text. + pub event_kind: AccountAuthAuditEventKind, + /// Stable success or rejection outcome. + pub outcome: AccountAuthAuditOutcome, + /// Affected account when it is safe and known. + pub account_id: Option, + /// Affected session family when one exists. + pub session_id: Option, + /// Client class involved in the authentication flow. + pub client_kind: Option, + /// Optional deployment-keyed digest of a canonical identifier. + pub identifier_tag: Option>, + /// Optional deployment-keyed digest of a canonical network prefix. + pub network_tag: Option>, + /// Optional bounded static reason code containing no user input. + pub reason_code: Option, + /// UTC timestamp at which the event occurred. + pub created_at: DateTime, +} + +/// Revocable first-party session storing only a short-lived access-token digest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountSessionRecord { /// Stable internal session identifier. pub id: Uuid, /// Account authenticated by the session. pub account_id: Uuid, - /// SHA-256 digest of the random token returned once to the client. + /// SHA-256 digest of the current random access token returned once to the client. pub token_digest: Vec, /// Client class controlling token presentation. pub client_kind: AccountSessionClientKind, @@ -231,14 +418,293 @@ pub struct AccountSessionRecord { pub created_at: DateTime, /// UTC timestamp of the most recent authenticated use. pub last_seen_at: DateTime, + /// Exclusive expiry of the current short-lived access token. + pub access_expires_at: DateTime, /// Sliding inactivity expiry. pub idle_expires_at: DateTime, /// Non-extendable absolute expiry. pub absolute_expires_at: DateTime, + /// Most recent second-factor verification inherited by this session. + pub mfa_verified_at: Option>, /// Explicit revocation timestamp. pub revoked_at: Option>, } +/// Digest-only initial credentials for creating one session family atomically. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountSessionIssuance { + /// Persisted session family carrying the current access-token digest. + pub session: AccountSessionRecord, + /// Stable identifier for refresh generation zero. + pub refresh_token_id: Uuid, + /// SHA-256 digest of the initial random refresh token. + pub refresh_token_digest: Vec, + /// Exclusive expiry of the initial refresh token. + pub refresh_expires_at: DateTime, +} + +/// Atomic session-family creation request with its sanitized success audit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountSessionCreationRequest { + /// Access and initial refresh credentials to persist together. + pub issuance: AccountSessionIssuance, + /// Session-created success event committed in the same transaction. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Atomic request to rotate one presented refresh token and its access token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountSessionRefreshRequest { + /// SHA-256 digest of the refresh token presented by the client. + pub presented_refresh_token_digest: Vec, + /// SHA-256 digest of the replacement access token. + pub replacement_access_token_digest: Vec, + /// Exclusive expiry of the replacement access token. + pub replacement_access_expires_at: DateTime, + /// Replacement sliding inactivity expiry for the refreshed family. + pub replacement_idle_expires_at: DateTime, + /// Stable identifier for the replacement refresh generation. + pub replacement_refresh_token_id: Uuid, + /// SHA-256 digest of the replacement refresh token. + pub replacement_refresh_token_digest: Vec, + /// Exclusive expiry of the replacement refresh token. + pub replacement_refresh_expires_at: DateTime, + /// UTC timestamp committed for consumption, rotation, or replay revocation. + pub rotated_at: DateTime, + /// Success event committed only when rotation succeeds. + pub success_audit_event: AccountAuthAuditEventRecord, + /// Replay event committed only when a consumed generation is presented. + pub replay_audit_event: AccountAuthAuditEventRecord, +} + +/// Security-preserving result of one refresh-token transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountSessionRefreshResult { + /// The access token and refresh generation rotated successfully. + Rotated(AccountSessionRecord), + /// A consumed generation was replayed and the complete family was revoked. + ReplayRevoked, + /// The digest was unknown, expired, or belonged to an unusable family. + Rejected, +} + +/// Lifecycle state for encrypted TOTP authenticator metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountMfaAuthenticatorState { + /// The authenticator awaits a proof-of-possession confirmation. + Pending, + /// The authenticator may satisfy second-factor challenges. + Active, + /// The authenticator is retained only as security history. + Disabled, +} + +/// Caller-encrypted TOTP secret metadata containing no plaintext secret. +#[derive(Clone, PartialEq, Eq)] +pub struct EncryptedTotpSecret { + /// Opaque authenticated ciphertext containing the TOTP seed. + pub ciphertext: Vec, + /// Random 192-bit XChaCha20-Poly1305 nonce. + pub nonce: [u8; 24], + /// Deployment-managed encryption-key version required for decryption. + pub key_version: i16, +} + +/// Durable encrypted TOTP authenticator state for one account. +#[derive(Clone, PartialEq, Eq)] +pub struct AccountMfaAuthenticatorRecord { + /// Stable authenticator identifier. + pub id: Uuid, + /// Account that owns the authenticator. + pub account_id: Uuid, + /// Pending, active, or disabled lifecycle state. + pub state: AccountMfaAuthenticatorState, + /// Encrypted TOTP seed and deployment key metadata. + pub secret: EncryptedTotpSecret, + /// Exclusive deadline for confirming a pending enrollment. + pub pending_expires_at: Option>, + /// Greatest successfully consumed TOTP timestep. + pub last_used_timestep: Option, + /// UTC timestamp when the encrypted metadata was created. + pub created_at: DateTime, + /// UTC timestamp when enrollment was confirmed. + pub activated_at: Option>, + /// UTC timestamp when the authenticator stopped being active. + pub disabled_at: Option>, +} + +/// Digest-only seed for one high-entropy MFA recovery code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountMfaRecoveryCodeSeed { + /// Stable recovery-code row identifier. + pub id: Uuid, + /// SHA-256 digest of the random recovery code shown once to the account. + pub code_digest: Vec, +} + +/// Atomic request to begin or replace a pending TOTP enrollment. +#[derive(Clone, PartialEq, Eq)] +pub struct AccountMfaEnrollmentRequest { + /// Pending encrypted authenticator record to persist. + pub authenticator: AccountMfaAuthenticatorRecord, + /// Enrollment-started success event committed with the pending state. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Atomic request to activate a verified pending TOTP authenticator. +#[derive(Clone, PartialEq, Eq)] +pub struct AccountMfaActivationRequest { + /// Account whose pending authenticator is being activated. + pub account_id: Uuid, + /// Exact pending authenticator that passed proof of possession. + pub authenticator_id: Uuid, + /// TOTP timestep consumed by enrollment confirmation. + pub verified_timestep: i64, + /// Digest-only high-entropy recovery codes created with activation. + pub recovery_codes: Vec, + /// UTC timestamp committed as the activation and old-authenticator disable time. + pub activated_at: DateTime, + /// Enrollment-activated success event committed with the swap. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Atomic request to disable active MFA for an unprivileged account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountMfaDisableRequest { + /// Account whose active authenticator is being disabled. + pub account_id: Uuid, + /// UTC timestamp committed as the disable time. + pub disabled_at: DateTime, + /// MFA-disabled success event committed with the state change. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Digest-only expiring challenge issued after password verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountMfaLoginChallengeRecord { + /// Stable challenge identifier. + pub id: Uuid, + /// Account that passed the first authentication factor. + pub account_id: Uuid, + /// SHA-256 digest of the random challenge token. + pub token_digest: Vec, + /// Client class to which challenge completion is bound. + pub client_kind: AccountSessionClientKind, + /// UTC challenge creation timestamp. + pub created_at: DateTime, + /// Exclusive challenge-completion deadline. + pub expires_at: DateTime, + /// Successful one-time completion timestamp. + pub consumed_at: Option>, +} + +/// Atomic request to create a password-bound MFA login challenge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountMfaChallengeCreationRequest { + /// Digest-only challenge record to persist. + pub challenge: AccountMfaLoginChallengeRecord, + /// Challenge-created success event committed in the same transaction. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Database-fenced proof already verified against an encrypted authenticator. +#[derive(Clone, PartialEq, Eq)] +pub enum AccountMfaChallengeProof { + /// TOTP timestep whose code was verified by the caller. + TotpTimestep(i64), + /// SHA-256 digest of a presented high-entropy recovery code. + RecoveryCodeDigest(Vec), +} + +/// Atomic request to consume an MFA challenge and issue a verified session. +#[derive(Clone, PartialEq, Eq)] +pub struct AccountMfaChallengeCompletionRequest { + /// SHA-256 digest of the random challenge token. + pub challenge_token_digest: Vec, + /// Exact active authenticator whose secret or recovery code verified the proof. + pub authenticator_id: Uuid, + /// TOTP timestep or recovery-code digest to consume once. + pub proof: AccountMfaChallengeProof, + /// Access and initial refresh credentials to persist on success. + pub issuance: AccountSessionIssuance, + /// UTC timestamp used for expiry, consumption, and MFA assurance. + pub completed_at: DateTime, + /// Challenge-completed success event committed with the session. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Result of atomically completing one MFA login challenge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountMfaChallengeCompletionResult { + /// The proof and challenge were consumed and a session family was issued. + Completed(AccountSessionRecord), + /// The challenge or proof was unusable without exposing which condition failed. + Rejected, +} + +/// Digest-only native authorization code bound to an exact S256 request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeAuthorizationCodeRecord { + /// Stable authorization-code identifier. + pub id: Uuid, + /// Browser-authenticated account authorizing the native client. + pub account_id: Uuid, + /// SHA-256 digest of the random one-time authorization code. + pub token_digest: Vec, + /// Desktop or CLI client receiving the code. + pub client_kind: AccountSessionClientKind, + /// Exact IP-literal loopback redirect URI string. + pub redirect_uri: String, + /// Decoded 32-byte S256 PKCE challenge. + pub pkce_challenge: Vec, + /// MFA assurance inherited from the authorizing browser session. + pub mfa_verified_at: Option>, + /// UTC code creation timestamp. + pub created_at: DateTime, + /// Exclusive code-exchange deadline. + pub expires_at: DateTime, + /// Successful one-time exchange timestamp. + pub consumed_at: Option>, +} + +/// Atomic request to issue one native authorization code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeAuthorizationCodeCreationRequest { + /// Exact digest-only authorization record to persist. + pub code: NativeAuthorizationCodeRecord, + /// Code-created success event committed in the same transaction. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Atomic S256 exchange request for one native authorization code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeAuthorizationCodeExchangeRequest { + /// SHA-256 digest of the random authorization code. + pub code_token_digest: Vec, + /// Desktop or CLI client class expected by the code. + pub client_kind: AccountSessionClientKind, + /// Exact redirect URI string expected by the code. + pub redirect_uri: String, + /// SHA-256 digest of the presented PKCE verifier. + pub pkce_challenge: Vec, + /// Access and initial refresh credentials to persist on success. + pub issuance: AccountSessionIssuance, + /// UTC timestamp used for expiry and one-time consumption. + pub exchanged_at: DateTime, + /// Code-consumed success event committed with the session. + pub audit_event: AccountAuthAuditEventRecord, +} + +/// Result of atomically exchanging one native authorization code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeAuthorizationCodeExchangeResult { + /// The exact code, client, redirect, and S256 challenge issued a session. + Exchanged(AccountSessionRecord), + /// The exchange failed without revealing which binding was unusable. + Rejected, +} + /// Atomic first-party registration input consumed with one invitation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalAccountRegistrationRequest { @@ -248,8 +714,10 @@ pub struct LocalAccountRegistrationRequest { pub account: AccountRecord, /// Argon2id password credential to create. pub credential: AccountPasswordCredentialRecord, - /// Initial authenticated session to create. - pub session: AccountSessionRecord, + /// Initial authenticated access and refresh credentials to create. + pub session: AccountSessionIssuance, + /// Session-created success event committed with registration. + pub audit_event: AccountAuthAuditEventRecord, } /// Result of an atomic first-party invitation redemption. diff --git a/crates/frameshift-cli/src/cmd/account.rs b/crates/frameshift-cli/src/cmd/account.rs index 5036626..0d829f7 100644 --- a/crates/frameshift-cli/src/cmd/account.rs +++ b/crates/frameshift-cli/src/cmd/account.rs @@ -1,22 +1,23 @@ //! Account session and administrator account-control commands. //! -//! OIDC login uses the system browser and a loopback Authorization Code callback -//! with S256 PKCE. First-party credentials use hidden terminal prompts. Session -//! commands accept no password, invitation, or bearer-token argument. +//! OIDC and first-party login use the system browser and an exact loopback +//! Authorization Code callback with S256 PKCE. Native processes never collect +//! account passwords, MFA codes, invitation tokens, or browser cookies. -use std::io::{IsTerminal as _, Read as _, Write as _}; +use std::io::{Read as _, Write as _}; use std::net::{IpAddr, SocketAddr, TcpListener, TcpStream}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use frameshift_catalog::{AccountInviteStatus, AccountStatus, PlatformRole}; use frameshift_client::account::{ - assign_account_platform_role, create_publisher_profile, get_account, get_auth_config, - get_publisher_profile, issue_account_invite, list_account_invite_requests, login_local_account, - logout_local_account, register_local_account, review_account_invite_request, - revoke_account_platform_role, set_account_status, update_account_profile, - update_publisher_profile, AccountInviteReviewStatus, AccountView, IssuedAccountInvite, - LocalAccountSession, NativeAuthClient, + assign_account_platform_role, begin_native_authorization, complete_native_authorization, + create_publisher_profile, get_account, get_auth_config, get_publisher_profile, + issue_account_invite, list_account_invite_requests, logout_local_account, + refresh_local_account, review_account_invite_request, revoke_account_platform_role, + set_account_status, update_account_profile, update_publisher_profile, AccountAuthConfig, + AccountInviteReviewStatus, AccountView, IssuedAccountInvite, LocalAccountSession, + NativeAuthClient, NativeAuthorizationIntent, }; use frameshift_client::session::{AuthenticatedSession, SessionClient, SessionClientConfig}; use frameshift_client::session_store::{ @@ -293,7 +294,7 @@ pub struct AccountLoginArgs { /// Registry API base URL; defaults to `FRAMESHIFT_REGISTRY_URL` or production. #[arg(long)] pub server: Option, - /// Use first-party password login when the registry also advertises OIDC. + /// Use browser-owned first-party login when the registry also advertises OIDC. #[arg(long)] pub first_party: bool, /// OIDC issuer override; defaults to `FRAMESHIFT_OIDC_ISSUER` or registry discovery. @@ -323,14 +324,20 @@ pub struct AccountRegisterArgs { /// Registry API base URL; defaults to `FRAMESHIFT_REGISTRY_URL` or production. #[arg(long)] pub server: Option, + /// Exact native loopback callback URI. + #[arg(long, default_value = DEFAULT_REDIRECT_URI)] + pub redirect_uri: String, + /// Seconds allowed to complete browser registration and authorization. + #[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECS)] + pub timeout_secs: u64, } /// Authentication mode selected from explicit intent and registry capabilities. enum AccountLoginMode { /// OIDC system-browser flow through the exact issuer. Oidc(Url), - /// First-party password flow through hidden terminal prompts. - FirstParty, + /// Browser-owned first-party flow using the advertised portal. + FirstParty(AccountAuthConfig), } /// Accepted callback URL paired with its response stream. @@ -550,7 +557,9 @@ fn run_login(args: AccountLoginArgs) -> Result<(), CliError> { let registry_url = Url::parse(&server).map_err(|error| CliError::Account(error.to_string()))?; match resolve_login_mode(&server, args.first_party, args.issuer.clone())? { AccountLoginMode::Oidc(issuer) => run_oidc_login(args, server, registry_url, issuer), - AccountLoginMode::FirstParty => run_first_party_login(&server, registry_url), + AccountLoginMode::FirstParty(config) => { + run_first_party_login(args, server, registry_url, &config) + } } } @@ -608,24 +617,31 @@ fn run_oidc_login( Ok(()) } -/// Prompt for first-party credentials and persist one opaque bearer session. -fn run_first_party_login(server: &str, registry_url: Url) -> Result<(), CliError> { - require_interactive_terminal()?; - let email = prompt_line("Email: ")?; - let password = prompt_secret("Password: ", "password")?; - let authenticated = login_local_account(server, &email, &password, NativeAuthClient::Cli) - .map_err(|error| CliError::Account(error.to_string()))?; - persist_first_party_session(registry_url, &authenticated)?; - let view = get_account(server, authenticated.session.access_token()) +/// Complete browser-owned first-party login and persist its rotating session. +fn run_first_party_login( + args: AccountLoginArgs, + server: String, + registry_url: Url, + config: &AccountAuthConfig, +) -> Result<(), CliError> { + let authenticated = run_native_authorization( + &server, + registry_url, + config, + &args.redirect_uri, + args.timeout_secs, + NativeAuthorizationIntent::Login, + )?; + let view = get_account(&server, authenticated.session.access_token()) .map_err(|error| CliError::Account(error.to_string()))?; println!("logged in to {server}"); print_account(&view); Ok(()) } -/// Redeem one invitation through hidden prompts and persist the initial session. +/// Redeem one invitation entirely in the browser and persist the native session. fn run_register(args: AccountRegisterArgs) -> Result<(), CliError> { - let server = args.server.unwrap_or_else(registry_base_url); + let server = args.server.clone().unwrap_or_else(registry_base_url); validate_server_url(&server)?; let config = get_auth_config(&server).map_err(|error| CliError::Account(error.to_string()))?; if !config.first_party_enabled { @@ -638,26 +654,52 @@ fn run_register(args: AccountRegisterArgs) -> Result<(), CliError> { "registry does not advertise invite-only account registration".to_string(), )); } - require_interactive_terminal()?; - let invite = prompt_secret("Invitation token: ", "invitation token")?; - let email = prompt_line("Email: ")?; - let display_name = prompt_line_allow_empty("Display name (optional): ")?; - let password = prompt_confirmed_password()?; - let authenticated = register_local_account( - &server, - &invite, - &email, - (!display_name.is_empty()).then_some(display_name.as_str()), - &password, - NativeAuthClient::Cli, - ) - .map_err(|error| CliError::Account(error.to_string()))?; let registry_url = Url::parse(&server).map_err(|error| CliError::Account(error.to_string()))?; - persist_first_party_session(registry_url, &authenticated)?; + run_native_authorization( + &server, + registry_url, + &config, + &args.redirect_uri, + args.timeout_secs, + NativeAuthorizationIntent::Register, + )?; println!("registered and logged in to {server}"); Ok(()) } +/// Run one exact loopback browser flow and persist its rotating native credentials. +fn run_native_authorization( + server: &str, + registry_url: Url, + config: &AccountAuthConfig, + redirect_uri: &str, + timeout_secs: u64, + intent: NativeAuthorizationIntent, +) -> Result { + let redirect_uri = Url::parse(redirect_uri) + .map_err(|error| CliError::Account(format!("invalid redirect URI: {error}")))?; + let listener = bind_callback_listener(&redirect_uri)?; + let flow = + begin_native_authorization(config, NativeAuthClient::Cli, redirect_uri.clone(), intent) + .map_err(|error| CliError::Account(error.to_string()))?; + open_browser(&flow.authorization_url); + let mut callback = + wait_for_callback(&listener, &redirect_uri, Duration::from_secs(timeout_secs))?; + let authenticated = match complete_native_authorization(server, flow, &callback.url) { + Ok(authenticated) => authenticated, + Err(error) => { + respond_to_browser(&mut callback.stream, false); + return Err(CliError::Account(error.to_string())); + } + }; + if let Err(error) = persist_first_party_session(registry_url, &authenticated) { + respond_to_browser(&mut callback.stream, false); + return Err(error); + } + respond_to_browser(&mut callback.stream, true); + Ok(authenticated) +} + /// Persist one first-party session and revoke it if local storage fails. fn persist_first_party_session( registry_url: Url, @@ -690,16 +732,9 @@ fn run_status() -> Result<(), CliError> { ) { Ok(view) => view, Err(ClientError::RegistryRejected { status: 401, .. }) - if stored.session.refresh_token().is_some() - && matches!( - &stored.metadata.authentication, - SessionAuthentication::Oidc { .. } - ) => + if stored.session.refresh_token().is_some() => { - let session_client = session_client_for(&stored)?; - stored.session = session_client - .refresh(&stored.session) - .map_err(|error| CliError::Account(error.to_string()))?; + stored.session = refresh_stored_session(&stored)?; persist_loaded_session(&store, &stored)?; get_account( stored.metadata.registry_url.as_str(), @@ -811,7 +846,7 @@ fn resolve_login_mode( if first_party { return config .first_party_enabled - .then_some(AccountLoginMode::FirstParty) + .then_some(AccountLoginMode::FirstParty(config)) .ok_or_else(|| { CliError::Account("registry does not advertise first-party login".to_string()) }); @@ -822,7 +857,7 @@ fn resolve_login_mode( return Ok(AccountLoginMode::Oidc(issuer)); } if config.first_party_enabled { - return Ok(AccountLoginMode::FirstParty); + return Ok(AccountLoginMode::FirstParty(config)); } Err(CliError::Account( "registry omitted every enabled authentication provider".to_string(), @@ -849,60 +884,6 @@ fn nonempty_env(name: &str) -> Option { .filter(|value| !value.trim().is_empty()) } -/// Require an interactive terminal before reading first-party credentials. -fn require_interactive_terminal() -> Result<(), CliError> { - if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() { - Ok(()) - } else { - Err(CliError::Account( - "first-party credentials require an interactive terminal".to_string(), - )) - } -} - -/// Read one required visible line without accepting control characters. -fn prompt_line(prompt: &str) -> Result { - let value = prompt_line_allow_empty(prompt)?; - if value.is_empty() { - return Err(CliError::Account("a required value was empty".to_string())); - } - Ok(value) -} - -/// Read one optional visible line from the interactive terminal. -fn prompt_line_allow_empty(prompt: &str) -> Result { - eprint!("{prompt}"); - std::io::stderr().flush()?; - let mut value = String::new(); - std::io::stdin().read_line(&mut value)?; - let value = value.trim_end_matches(['\r', '\n']).to_string(); - if value.chars().any(char::is_control) { - return Err(CliError::Account( - "interactive account values must not contain control characters".to_string(), - )); - } - Ok(value) -} - -/// Read one non-empty secret through a hidden terminal prompt. -fn prompt_secret(prompt: &str, label: &str) -> Result { - let value = rpassword::prompt_password(prompt)?; - if value.is_empty() { - return Err(CliError::Account(format!("{label} cannot be empty"))); - } - Ok(SecretString::new(value)) -} - -/// Read and exactly confirm one new password through hidden prompts. -fn prompt_confirmed_password() -> Result { - let password = prompt_secret("Password: ", "password")?; - let confirmation = prompt_secret("Confirm password: ", "password confirmation")?; - if password.expose_secret() != confirmation.expose_secret() { - return Err(CliError::Account("passwords did not match".to_string())); - } - Ok(password) -} - /// Bind only the exact IP loopback callback address and explicit port. fn bind_callback_listener(redirect_uri: &Url) -> Result { if redirect_uri.scheme() != "http" @@ -1071,26 +1052,41 @@ fn session_expires_soon(session: &AuthenticatedSession) -> bool { .is_some_and(|expiry| expiry <= unix_now().saturating_add(REFRESH_MARGIN_SECS)) } -/// Refresh one expiring OIDC session while leaving first-party sessions untouched. +/// Refresh one expiring OIDC or first-party session using its bound provider contract. fn refresh_loaded_session_if_needed( store: &SessionStore, stored: &mut StoredSession, ) -> Result<(), CliError> { - if matches!( - &stored.metadata.authentication, - SessionAuthentication::Oidc { .. } - ) && session_expires_soon(&stored.session) - && stored.session.refresh_token().is_some() - { - let session_client = session_client_for(stored)?; - stored.session = session_client - .refresh(&stored.session) - .map_err(|error| CliError::Account(error.to_string()))?; + if session_expires_soon(&stored.session) && stored.session.refresh_token().is_some() { + stored.session = refresh_stored_session(stored)?; persist_loaded_session(store, stored)?; } Ok(()) } +/// Refresh one stored session through OIDC discovery or native token rotation. +fn refresh_stored_session(stored: &StoredSession) -> Result { + match &stored.metadata.authentication { + SessionAuthentication::Oidc { .. } => session_client_for(stored)? + .refresh(&stored.session) + .map_err(|error| CliError::Account(error.to_string())), + SessionAuthentication::FirstParty => { + let refresh_token = stored.session.refresh_token().ok_or_else(|| { + CliError::Account( + "first-party session does not contain a refresh credential".to_string(), + ) + })?; + refresh_local_account( + stored.metadata.registry_url.as_str(), + refresh_token, + NativeAuthClient::Cli, + ) + .map(|authenticated| authenticated.session) + .map_err(|error| CliError::Account(error.to_string())) + } + } +} + /// Rewrite one refreshed session using its existing public metadata. fn persist_loaded_session(store: &SessionStore, stored: &StoredSession) -> Result<(), CliError> { store diff --git a/crates/frameshift-client/Cargo.toml b/crates/frameshift-client/Cargo.toml index e374ac3..af66792 100644 --- a/crates/frameshift-client/Cargo.toml +++ b/crates/frameshift-client/Cargo.toml @@ -71,3 +71,5 @@ libc = "0.2" tempfile.workspace = true [dev-dependencies] +# Drives async Studio validation in publication-boundary regression fixtures. +tokio.workspace = true diff --git a/crates/frameshift-client/src/account.rs b/crates/frameshift-client/src/account.rs index 8f2c5aa..c243e8e 100644 --- a/crates/frameshift-client/src/account.rs +++ b/crates/frameshift-client/src/account.rs @@ -3,14 +3,22 @@ //! Access tokens enter only through [`SecretString`] and are attached solely //! to the registry request's `Authorization` header. +use std::fmt; +use std::net::IpAddr; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; use chrono::{DateTime, Utc}; use frameshift_catalog::{ AccountInviteRecord, AccountInviteRequestRecord, AccountInviteStatus, AccountRecord, AccountStatus, PlatformRole, PlatformRoleRecord, PublisherMembershipRecord, PublisherProfileRecord, }; +use rand_core::{OsRng, RngCore as _}; use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use url::Url; use uuid::Uuid; use zeroize::Zeroizing; @@ -39,6 +47,9 @@ pub struct AccountAuthConfig { /// First-party registration policy advertised by the registry. #[serde(default)] pub registration: Option, + /// Trusted browser portal used for first-party native authorization. + #[serde(default)] + pub native_authorization_url: Option, } /// Native first-party client presentation requested from the registry. @@ -51,6 +62,85 @@ pub enum NativeAuthClient { Cli, } +/// Stable wire spellings for native client presentations. +impl NativeAuthClient { + /// Return the exact query and JSON value bound into the authorization flow. + const fn as_str(self) -> &'static str { + match self { + Self::Desktop => "desktop", + Self::Cli => "cli", + } + } +} + +/// Browser experience requested by one native authorization flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeAuthorizationIntent { + /// Authenticate an existing account before authorizing the native client. + Login, + /// Redeem an invitation in the browser before authorizing the native client. + Register, +} + +/// Stable browser intents understood by the first-party account portal. +impl NativeAuthorizationIntent { + /// Return the exact portal query value for this browser experience. + const fn as_str(self) -> &'static str { + match self { + Self::Login => "native_authorize", + Self::Register => "native_register", + } + } +} + +/// Pending first-party native browser authorization bound to S256 PKCE. +pub struct NativeAuthorizationFlow { + /// Trusted HTTPS portal URL that the caller opens in the system browser. + pub authorization_url: Url, + /// Exact IP-literal loopback callback registered by the native client. + redirect_uri: Url, + /// Desktop or CLI presentation bound into the one-time code. + client_kind: NativeAuthClient, + /// Random callback state retained for exact constant-time comparison. + state: SecretString, + /// Random PKCE verifier retained only until one code exchange. + code_verifier: SecretString, +} + +/// Redact every random flow binding from diagnostics. +impl fmt::Debug for NativeAuthorizationFlow { + /// Render only query-free portal and loopback URLs. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut portal = self.authorization_url.clone(); + portal.set_query(None); + formatter + .debug_struct("NativeAuthorizationFlow") + .field("authorization_url", &portal) + .field("redirect_uri", &self.redirect_uri) + .field("client_kind", &self.client_kind) + .field("state", &"[REDACTED]") + .field("code_verifier", &"[REDACTED]") + .finish() + } +} + +/// Native first-party authorization or token-lifecycle failure. +#[derive(Debug, thiserror::Error)] +pub enum NativeAuthError { + /// Registry discovery or a token endpoint failed. + #[error(transparent)] + Registry(#[from] ClientError), + /// Trusted portal or loopback callback configuration was unsafe. + #[error("invalid native authorization configuration: {0}")] + InvalidConfiguration(String), + /// The browser callback did not match the pending flow. + #[error("native authorization callback rejected: {0}")] + InvalidCallback(String), + /// A successful registry response violated the frozen token contract. + #[error("native authorization response rejected: {0}")] + InvalidResponse(String), +} + /// Successful first-party authentication with its secret-bearing session. pub struct LocalAccountSession { /// Durable authenticated account returned by the registry. @@ -71,50 +161,59 @@ impl std::fmt::Debug for LocalAccountSession { } } -/// Password login request serialized only at the HTTP boundary. +/// Native authorization-code exchange fields serialized at the HTTP boundary. #[derive(Serialize)] -struct LoginLocalAccountRequest<'a> { - /// Normalized sign-in email supplied by the caller. - email: &'a str, - /// Secret account password exposed only to the serializer. - password: &'a str, - /// Native session presentation. +struct NativeAuthorizationCodeRequest<'a> { + /// Frozen grant identifier for one-time native codes. + grant_type: &'static str, + /// One-time random authorization code exposed only to the serializer. + code: &'a str, + /// Secret S256 verifier exposed only to the serializer. + code_verifier: &'a str, + /// Exact IP-literal loopback URI bound into the code. + redirect_uri: &'a str, + /// Desktop or CLI presentation bound into the code. client_kind: NativeAuthClient, } -/// Invitation registration request serialized only at the HTTP boundary. +/// Native refresh request serialized only at the HTTP boundary. #[derive(Serialize)] -struct RegisterLocalAccountRequest<'a> { - /// Secret one-time invitation token exposed only to the serializer. - invite_token: &'a str, - /// Invitation-bound email address. - email: &'a str, - /// Optional account display name. - display_name: Option<&'a str>, - /// Secret account password exposed only to the serializer. - password: &'a str, - /// Native session presentation. +struct NativeRefreshRequest<'a> { + /// Desktop or CLI presentation bound into the session family. client_kind: NativeAuthClient, + /// Current rotating refresh credential exposed only to the serializer. + refresh_token: &'a str, } -/// Wire response containing a bearer token that is wiped after conversion. +/// Wire response carrying rotating native credentials that are wiped after conversion. #[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct LocalAuthResponse { +struct NativeTokenResponse { /// Durable authenticated account. account: AccountRecord, - /// Opaque token required for native clients. - token: Option, - /// Non-extendable session expiry. + /// Current short-lived opaque access token. + access_token: Option, + /// Current rotating opaque refresh token. + refresh_token: Option, + /// Required HTTP authorization scheme. + token_type: String, + /// Exclusive access-token expiry. expires_at: DateTime, + /// Exclusive refresh-generation expiry. + refresh_expires_at: DateTime, + /// Non-extendable session-family expiry. + session_expires_at: DateTime, } -/// Wipe the raw response token when its temporary wire value leaves scope. -impl Drop for LocalAuthResponse { - /// Zero the optional raw token string. +/// Wipe temporary raw native credentials when their wire value leaves scope. +impl Drop for NativeTokenResponse { + /// Zero both optional token strings. fn drop(&mut self) { use zeroize::Zeroize as _; - if let Some(token) = &mut self.token { + if let Some(token) = &mut self.access_token { + token.zeroize(); + } + if let Some(token) = &mut self.refresh_token { token.zeroize(); } } @@ -280,48 +379,95 @@ pub fn get_auth_config(server_url: &str) -> Result, - password: &SecretString, +/// Returns a configuration error when the registry does not advertise the exact +/// HTTPS account portal or the callback is not an exact IP-literal loopback URL. +pub fn begin_native_authorization( + config: &AccountAuthConfig, client_kind: NativeAuthClient, -) -> Result { - let request = RegisterLocalAccountRequest { - invite_token: invite_token.expose_secret(), - email, - display_name, - password: password.expose_secret(), + redirect_uri: Url, + intent: NativeAuthorizationIntent, +) -> Result { + if !config.first_party_enabled { + return Err(NativeAuthError::InvalidConfiguration( + "registry does not advertise first-party authentication".to_string(), + )); + } + let portal = config.native_authorization_url.as_deref().ok_or_else(|| { + NativeAuthError::InvalidConfiguration( + "registry omitted its native authorization portal".to_string(), + ) + })?; + let mut authorization_url = validate_native_portal(portal)?; + validate_native_redirect(&redirect_uri)?; + + let state = random_native_binding(); + let code_verifier = random_native_binding(); + let challenge = + URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.expose_secret().as_bytes())); + authorization_url + .query_pairs_mut() + .append_pair("intent", intent.as_str()) + .append_pair("client_kind", client_kind.as_str()) + .append_pair("redirect_uri", redirect_uri.as_str()) + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", state.expose_secret()); + + Ok(NativeAuthorizationFlow { + authorization_url, + redirect_uri, client_kind, + state, + code_verifier, + }) +} + +/// Exchange an exact native browser callback for rotating first-party credentials. +/// +/// # Errors +/// +/// Returns a callback error before transport when the loopback URL or state does +/// not exactly match the pending flow. Registry and response failures never +/// include the code, verifier, state, access token, or refresh token. +pub fn complete_native_authorization( + server_url: &str, + flow: NativeAuthorizationFlow, + callback_url: &Url, +) -> Result { + let (code, _returned_state) = validate_native_callback(&flow, callback_url)?; + let redirect_uri = flow.redirect_uri.as_str(); + let request = NativeAuthorizationCodeRequest { + grant_type: "authorization_code", + code: &code, + code_verifier: flow.code_verifier.expose_secret(), + redirect_uri, + client_kind: flow.client_kind, }; - post_local_auth(server_url, "register", request) + let response = post_native_token_json(server_url, &["native", "token"], &request)?; + native_session_from_response(response) } -/// Verify first-party credentials and create a native bearer session. +/// Rotate a first-party native refresh credential and access token. /// /// # Errors /// -/// Returns a registry URL, transport, status, size, JSON, token-validation, or -/// expiry error without including the password or bearer token. -pub fn login_local_account( +/// Returns registry, JSON, response-contract, token-validation, or expiry errors +/// without including either rotating credential. +pub fn refresh_local_account( server_url: &str, - email: &str, - password: &SecretString, + refresh_token: &SecretString, client_kind: NativeAuthClient, -) -> Result { - let request = LoginLocalAccountRequest { - email, - password: password.expose_secret(), +) -> Result { + let request = NativeRefreshRequest { client_kind, + refresh_token: refresh_token.expose_secret(), }; - post_local_auth(server_url, "login", request) + let response = post_native_token_json(server_url, &["refresh"], &request)?; + native_session_from_response(response) } /// Revoke one first-party bearer session at the registry. @@ -367,14 +513,16 @@ pub fn logout_local_account( } /// Submit one first-party credential request and validate its native session. -fn post_local_auth( +fn post_native_token_json( server_url: &str, - operation: &str, - request: impl Serialize, -) -> Result { - let url = crate::publisher::registry_endpoint_url(server_url, &["v1", "auth", operation])?; + operation: &[&str], + request: &impl Serialize, +) -> Result { + let mut segments = vec!["v1", "auth"]; + segments.extend_from_slice(operation); + let url = crate::publisher::registry_endpoint_url(server_url, &segments)?; let body = Zeroizing::new( - serde_json::to_vec(&request) + serde_json::to_vec(request) .map_err(|error| ClientError::JsonSerialize(error.to_string()))?, ); let response = ureq::AgentBuilder::new() @@ -384,50 +532,180 @@ fn post_local_auth( .post(url.as_str()) .set("Content-Type", "application/json") .send_bytes(body.as_slice()); - let mut response: LocalAuthResponse = match response { - Ok(response) => crate::registry::response_json_bounded(response, url.as_str())?, - Err(ureq::Error::Status(status, response)) => { - return Err(ClientError::RegistryRejected { - url: url.to_string(), - status, - message: crate::registry::response_text_bounded(response, url.as_str()), - }); - } - Err(error) => { - return Err(ClientError::RegistryHttp { - url: url.to_string(), - detail: error.to_string(), - }); - } - }; - let token = response - .token - .take() - .ok_or_else(|| ClientError::RegistryRejected { + match response { + Ok(response) => crate::registry::response_json_bounded(response, url.as_str()) + .map_err(NativeAuthError::from), + Err(ureq::Error::Status(status, response)) => Err(ClientError::RegistryRejected { url: url.to_string(), - status: 502, - message: "registry omitted the native bearer token".to_string(), - })?; - let expires_at = u64::try_from(response.expires_at.timestamp()).map_err(|_| { - ClientError::RegistryRejected { + status, + message: crate::registry::response_text_bounded(response, url.as_str()), + } + .into()), + Err(error) => Err(ClientError::RegistryHttp { url: url.to_string(), - status: 502, - message: "registry returned an invalid native session expiry".to_string(), + detail: error.to_string(), } + .into()), + } +} + +/// Convert and validate one frozen native token response. +fn native_session_from_response( + mut response: NativeTokenResponse, +) -> Result { + if response.token_type != "Bearer" { + return Err(NativeAuthError::InvalidResponse( + "registry returned an unsupported token type".to_string(), + )); + } + let now = Utc::now(); + if response.expires_at <= now + || response.refresh_expires_at < response.expires_at + || response.session_expires_at < response.refresh_expires_at + { + return Err(NativeAuthError::InvalidResponse( + "registry returned inconsistent native session expiries".to_string(), + )); + } + let access_token = response.access_token.take().ok_or_else(|| { + NativeAuthError::InvalidResponse("registry omitted the native access token".to_string()) + })?; + let refresh_token = response.refresh_token.take().ok_or_else(|| { + NativeAuthError::InvalidResponse("registry omitted the native refresh token".to_string()) + })?; + let expires_at = u64::try_from(response.expires_at.timestamp()).map_err(|_| { + NativeAuthError::InvalidResponse( + "registry returned an invalid native access expiry".to_string(), + ) + })?; + let session = AuthenticatedSession::from_first_party_tokens( + SecretString::new(access_token), + SecretString::new(refresh_token), + expires_at, + ) + .map_err(|_| { + NativeAuthError::InvalidResponse( + "registry returned invalid native token material".to_string(), + ) })?; - let session = - AuthenticatedSession::from_first_party_bearer(SecretString::new(token), expires_at) - .map_err(|_| ClientError::RegistryRejected { - url: url.to_string(), - status: 502, - message: "registry returned an invalid native bearer session".to_string(), - })?; Ok(LocalAccountSession { account: response.account.clone(), session, }) } +/// Parse and validate the registry-advertised browser portal. +fn validate_native_portal(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| { + NativeAuthError::InvalidConfiguration( + "native authorization portal is not a valid URL".to_string(), + ) + })?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/account/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(NativeAuthError::InvalidConfiguration( + "native authorization portal must be a credential-free HTTPS /account/ URL".to_string(), + )); + } + Ok(url) +} + +/// Require an exact HTTP callback on an IP-literal loopback address and explicit port. +fn validate_native_redirect(url: &Url) -> Result<(), NativeAuthError> { + let loopback = url + .host_str() + .and_then(|host| host.parse::().ok()) + .is_some_and(|address| address.is_loopback()); + if url.scheme() != "http" + || !loopback + || url.port().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(NativeAuthError::InvalidConfiguration( + "native callback must be an exact query-free HTTP IP-literal loopback URL with an explicit port" + .to_string(), + )); + } + Ok(()) +} + +/// Validate one callback and return its one-time code and state without logging them. +fn validate_native_callback( + flow: &NativeAuthorizationFlow, + callback_url: &Url, +) -> Result<(String, String), NativeAuthError> { + if callback_url.fragment().is_some() { + return Err(NativeAuthError::InvalidCallback( + "callback included a fragment".to_string(), + )); + } + let mut callback_base = callback_url.clone(); + callback_base.set_query(None); + if callback_base != flow.redirect_uri { + return Err(NativeAuthError::InvalidCallback( + "callback URL did not match the pending loopback listener".to_string(), + )); + } + let mut code = None; + let mut state = None; + for (name, value) in callback_url.query_pairs() { + let slot = match name.as_ref() { + "code" => &mut code, + "state" => &mut state, + _ => { + return Err(NativeAuthError::InvalidCallback( + "callback included an unsupported query field".to_string(), + )); + } + }; + if value.is_empty() || slot.replace(value.into_owned()).is_some() { + return Err(NativeAuthError::InvalidCallback( + "callback included an empty or repeated query field".to_string(), + )); + } + } + let code = code.ok_or_else(|| { + NativeAuthError::InvalidCallback("callback omitted the authorization code".to_string()) + })?; + let state = state.ok_or_else(|| { + NativeAuthError::InvalidCallback("callback omitted the authorization state".to_string()) + })?; + if !constant_time_equal(flow.state.expose_secret().as_bytes(), state.as_bytes()) { + return Err(NativeAuthError::InvalidCallback( + "callback state did not match the pending authorization".to_string(), + )); + } + Ok((code, state)) +} + +/// Generate one 256-bit URL-safe native authorization binding. +fn random_native_binding() -> SecretString { + let mut bytes = Zeroizing::new([0_u8; 32]); + OsRng.fill_bytes(bytes.as_mut()); + SecretString::new(URL_SAFE_NO_PAD.encode(bytes.as_ref())) +} + +/// Compare two byte strings without data-dependent early exit. +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + let mut difference = left.len() ^ right.len(); + let max_len = left.len().max(right.len()); + for index in 0..max_len { + let left_byte = left.get(index).copied().unwrap_or(0); + let right_byte = right.get(index).copied().unwrap_or(0); + difference |= usize::from(left_byte ^ right_byte); + } + difference == 0 +} + /// Fetch the current account view from one FrameShift registry. /// /// # Errors @@ -868,6 +1146,26 @@ mod tests { r#"{"id":"00000000-0000-0000-0000-000000000001","issuer":"https://issuer.example","subject":"subject-1","email":"alice@example.com","display_name":"Alice","status":"active","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"# } + /// Return a first-party configuration advertising the exact browser portal. + fn native_auth_config() -> AccountAuthConfig { + AccountAuthConfig { + enabled: true, + issuer: None, + audience: None, + first_party_enabled: true, + registration: Some("invite_only".to_string()), + native_authorization_url: Some("https://market.example/account/".to_string()), + } + } + + /// Return one frozen rotating native token response with future expiries. + fn native_token_json(access_token: &str, refresh_token: &str) -> String { + format!( + r#"{{"account":{},"access_token":"{access_token}","refresh_token":"{refresh_token}","token_type":"Bearer","expires_at":"2099-01-01T00:00:00Z","refresh_expires_at":"2099-02-01T00:00:00Z","session_expires_at":"2099-03-01T00:00:00Z"}}"#, + account_json() + ) + } + /// Return one stable publisher profile JSON object for mutation responses. fn publisher_json() -> &'static str { r#"{"id":"00000000-0000-0000-0000-000000000002","handle":"gatekeeper","display_name":"Gatekeeper","biography":"Verifies releases.","moderation_status":"pending","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"# @@ -971,22 +1269,117 @@ mod tests { )); } - /// First-party login sends credentials only in JSON and redacts its bearer result. + /// Native browser authorization binds exact intent, client, callback, state, and S256 PKCE. #[test] - fn logs_in_with_native_bearer_without_debug_disclosure() { - let body = format!( - r#"{{"account":{},"token":"native-session-token","expires_at":"2099-01-01T00:00:00Z"}}"#, - account_json() - ); - let (server, handle) = serve_json_response(body); - let password = SecretString::new("correct horse battery staple".to_string()); - let authenticated = login_local_account( - &server, - "alice@example.com", - &password, + fn begins_exact_native_browser_authorization_without_debug_disclosure() { + let redirect = Url::parse("http://127.0.0.1:43119/callback").expect("redirect URL"); + let flow = begin_native_authorization( + &native_auth_config(), NativeAuthClient::Cli, + redirect.clone(), + NativeAuthorizationIntent::Login, + ); + let flow = flow.expect("native flow"); + let pairs: std::collections::HashMap<_, _> = + flow.authorization_url.query_pairs().into_owned().collect(); + assert_eq!(flow.authorization_url.path(), "/account/"); + assert_eq!( + pairs.get("intent").map(String::as_str), + Some("native_authorize") + ); + assert_eq!(pairs.get("client_kind").map(String::as_str), Some("cli")); + assert_eq!( + pairs.get("redirect_uri").map(String::as_str), + Some(redirect.as_str()) + ); + assert_eq!( + pairs.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert_eq!(pairs.get("state").map(String::len), Some(43)); + assert_eq!(pairs.get("code_challenge").map(String::len), Some(43)); + + let debug = format!("{flow:?}"); + assert!(!debug.contains(flow.state.expose_secret())); + assert!(!debug.contains(pairs.get("code_challenge").expect("PKCE challenge"))); + assert!(!debug.contains(flow.code_verifier.expose_secret())); + } + + /// Unsafe portal and loopback variants fail before a browser is opened. + #[test] + fn rejects_unsafe_native_authorization_configuration() { + let redirect = Url::parse("http://127.0.0.1:43119/callback").expect("redirect URL"); + for portal in [ + "http://market.example/account/", + "https://user@market.example/account/", + "https://market.example/account", + "https://market.example/account/?source=unsafe", + ] { + let mut config = native_auth_config(); + config.native_authorization_url = Some(portal.to_string()); + assert!(matches!( + begin_native_authorization( + &config, + NativeAuthClient::Desktop, + redirect.clone(), + NativeAuthorizationIntent::Register, + ), + Err(NativeAuthError::InvalidConfiguration(_)) + )); + } + for callback in [ + "http://localhost:43119/callback", + "http://192.0.2.1:43119/callback", + "https://127.0.0.1:43119/callback", + "http://127.0.0.1/callback", + "http://127.0.0.1:43119/callback?code=early", + ] { + assert!(matches!( + begin_native_authorization( + &native_auth_config(), + NativeAuthClient::Desktop, + Url::parse(callback).expect("callback URL"), + NativeAuthorizationIntent::Register, + ), + Err(NativeAuthError::InvalidConfiguration(_)) + )); + } + } + + /// Exact callback exchange sends only the frozen one-time code contract. + #[test] + fn exchanges_exact_native_callback_for_rotating_tokens() { + let redirect = Url::parse("http://127.0.0.1:43119/callback").expect("redirect URL"); + let flow = begin_native_authorization( + &native_auth_config(), + NativeAuthClient::Desktop, + redirect.clone(), + NativeAuthorizationIntent::Register, ) - .expect("native login"); + .expect("native flow"); + let expected_state = flow.state.expose_secret().clone(); + let expected_verifier = flow.code_verifier.expose_secret().clone(); + let expected_challenge = flow + .authorization_url + .query_pairs() + .find_map(|(name, value)| (name == "code_challenge").then(|| value.into_owned())) + .expect("PKCE challenge"); + assert_eq!( + URL_SAFE_NO_PAD.encode(Sha256::digest(expected_verifier.as_bytes())), + expected_challenge + ); + let mut callback = redirect; + callback + .query_pairs_mut() + .append_pair("code", "one-time-code") + .append_pair("state", &expected_state); + + let (server, handle) = serve_json_response(native_token_json( + "native-access-token", + "native-refresh-token", + )); + let authenticated = complete_native_authorization(&server, flow, &callback) + .expect("native callback exchange"); assert_eq!( authenticated.account.email.as_deref(), @@ -994,43 +1387,98 @@ mod tests { ); assert_eq!( authenticated.session.access_token().expose_secret(), - "native-session-token" + "native-access-token" + ); + assert_eq!( + authenticated + .session + .refresh_token() + .expect("refresh token") + .expose_secret(), + "native-refresh-token" ); let debug = format!("{authenticated:?}"); - assert!(!debug.contains("native-session-token")); - assert!(!debug.contains("correct horse battery staple")); + assert!(!debug.contains("native-access-token")); + assert!(!debug.contains("native-refresh-token")); let request = handle.join().expect("test server thread"); - assert!(request.starts_with("POST /v1/auth/login HTTP/1.1\r\n")); - assert!(request.contains("\"client_kind\":\"cli\"")); - assert!(request.contains("\"password\":\"correct horse battery staple\"")); + assert!(request.starts_with("POST /v1/auth/native/token HTTP/1.1\r\n")); + assert!(request.contains("\"grant_type\":\"authorization_code\"")); + assert!(request.contains("\"code\":\"one-time-code\"")); + assert!(request.contains(&format!("\"code_verifier\":\"{expected_verifier}\""))); + assert!(request.contains("\"redirect_uri\":\"http://127.0.0.1:43119/callback\"")); + assert!(request.contains("\"client_kind\":\"desktop\"")); assert!(!request.contains("Authorization:")); } - /// Invitation registration preserves native client kind and optional profile data. + /// Callback mismatches and ambiguous query fields fail before token exchange. #[test] - fn registers_invited_native_account() { - let body = format!( - r#"{{"account":{},"token":"registered-session-token","expires_at":"2099-01-01T00:00:00Z"}}"#, - account_json() - ); - let (server, handle) = serve_json_response(body); - let invite = SecretString::new("one-time-invite".to_string()); - let password = SecretString::new("a sufficiently long password".to_string()); - let authenticated = register_local_account( - &server, - &invite, - "alice@example.com", - Some("Alice"), - &password, - NativeAuthClient::Desktop, + fn rejects_mismatched_native_callbacks_before_transport() { + for query in [ + "code=one-time-code&state=wrong", + "code=one-time-code&code=duplicate&state=wrong", + "code=one-time-code&state=wrong&extra=value", + ] { + let redirect = Url::parse("http://127.0.0.1:43119/callback").expect("redirect URL"); + let flow = begin_native_authorization( + &native_auth_config(), + NativeAuthClient::Cli, + redirect.clone(), + NativeAuthorizationIntent::Login, + ) + .expect("native flow"); + let callback = Url::parse(&format!("{redirect}?{query}")).expect("callback URL"); + assert!(matches!( + complete_native_authorization("https://registry.example", flow, &callback), + Err(NativeAuthError::InvalidCallback(_)) + )); + } + + let redirect = Url::parse("http://127.0.0.1:43119/callback").expect("redirect URL"); + let flow = begin_native_authorization( + &native_auth_config(), + NativeAuthClient::Cli, + redirect, + NativeAuthorizationIntent::Login, ) - .expect("native registration"); + .expect("native flow"); + let state = flow.state.expose_secret().clone(); + let callback = Url::parse(&format!( + "http://127.0.0.1:43119/wrong?code=one-time-code&state={state}" + )) + .expect("callback URL"); + assert!(matches!( + complete_native_authorization("https://registry.example", flow, &callback), + Err(NativeAuthError::InvalidCallback(_)) + )); + } - assert_eq!(authenticated.account.display_name.as_deref(), Some("Alice")); - let request = handle.join().expect("test server thread"); - assert!(request.starts_with("POST /v1/auth/register HTTP/1.1\r\n")); - assert!(request.contains("\"invite_token\":\"one-time-invite\"")); - assert!(request.contains("\"client_kind\":\"desktop\"")); + /// Native refresh rotates both credentials using the exact client-bound request. + #[test] + fn refreshes_native_session_with_rotating_credentials() { + let (server, handle) = serve_json_response(native_token_json( + "rotated-access-token", + "rotated-refresh-token", + )); + let refresh = SecretString::new("current-refresh-token".to_string()); + let authenticated = refresh_local_account(&server, &refresh, NativeAuthClient::Cli) + .expect("native refresh"); + assert_eq!( + authenticated.session.access_token().expose_secret(), + "rotated-access-token" + ); + assert_eq!( + authenticated + .session + .refresh_token() + .expect("rotated refresh token") + .expose_secret(), + "rotated-refresh-token" + ); + let request = handle.join().expect("refresh request thread"); + assert!(request.starts_with("POST /v1/auth/refresh HTTP/1.1\r\n")); + assert!(request.contains("\"client_kind\":\"cli\"")); + assert!(request.contains("\"refresh_token\":\"current-refresh-token\"")); + assert!(!request.contains("Authorization:")); } /// Local logout uses the bearer header and requires an affirmative acknowledgement. diff --git a/crates/frameshift-client/src/publication.rs b/crates/frameshift-client/src/publication.rs index dc4ab57..881e8e9 100644 --- a/crates/frameshift-client/src/publication.rs +++ b/crates/frameshift-client/src/publication.rs @@ -477,7 +477,7 @@ mod tests { use chrono::{TimeZone as _, Utc}; use frameshift_catalog::{PublicationAppealCursor, PublicationLifecycleCursor}; - use frameshift_studio::Studio; + use frameshift_studio::{Studio, MIN_PUBLICATION_CONFORMANCE_THRESHOLD}; use super::*; @@ -487,7 +487,7 @@ mod tests { } /// Build a valid exact pre-review snapshot using the fixture key. - fn ready_snapshot(root: &std::path::Path) -> DraftSnapshot { + async fn ready_snapshot(root: &std::path::Path) -> DraftSnapshot { let source = root.join("source"); fs::create_dir_all(&source).unwrap(); fs::write( @@ -503,6 +503,15 @@ mod tests { let studio = Studio::open(root.join("studio")).unwrap(); let status = studio.import("fixture", "Fixture", &source).unwrap(); let inventory_hash = status.publication.inventory_hash; + let report = studio + .validate_draft( + "fixture", + MIN_PUBLICATION_CONFORMANCE_THRESHOLD, + &frameshift_conformance::MockRunner::new("unused"), + ) + .await + .unwrap(); + assert!(report.valid); studio .snapshot_for_review("fixture", &inventory_hash) .unwrap() @@ -578,10 +587,10 @@ mod tests { } /// Repeated preparation produces byte-identical archives and valid pack signatures. - #[test] - fn preparation_is_reproducible_and_signed() { + #[tokio::test] + async fn preparation_is_reproducible_and_signed() { let temporary = tempfile::tempdir().unwrap(); - let snapshot = ready_snapshot(temporary.path()); + let snapshot = ready_snapshot(temporary.path()).await; let first = prepare_publication(&snapshot, &signing_key()).unwrap(); let second = prepare_publication(&snapshot, &signing_key()).unwrap(); assert_eq!(first.binding, second.binding); @@ -598,10 +607,10 @@ mod tests { } /// Preparation refuses a signer that differs from the manifest identity. - #[test] - fn preparation_rejects_manifest_signer_mismatch() { + #[tokio::test] + async fn preparation_rejects_manifest_signer_mismatch() { let temporary = tempfile::tempdir().unwrap(); - let snapshot = ready_snapshot(temporary.path()); + let snapshot = ready_snapshot(temporary.path()).await; let different_key = SigningKey::from_bytes(&[18_u8; 32]); let error = match prepare_publication(&snapshot, &different_key) { Ok(_) => panic!("mismatched signer must fail"), @@ -611,10 +620,10 @@ mod tests { } /// Intent JSON carries every reviewed identity, exact hash, and idempotency identifier. - #[test] - fn intent_request_serializes_exact_binding() { + #[tokio::test] + async fn intent_request_serializes_exact_binding() { let temporary = tempfile::tempdir().unwrap(); - let snapshot = ready_snapshot(temporary.path()); + let snapshot = ready_snapshot(temporary.path()).await; let prepared = prepare_publication(&snapshot, &signing_key()).unwrap(); let request = CreatePublicationIntentRequest { id: Uuid::from_u128(1), @@ -648,10 +657,10 @@ mod tests { } /// Final review, intent confirmation, and submission snapshot share one exact binding. - #[test] - fn artifact_first_review_flow_preserves_one_binding() { + #[tokio::test] + async fn artifact_first_review_flow_preserves_one_binding() { let temporary = tempfile::tempdir().unwrap(); - let snapshot = ready_snapshot(temporary.path()); + let snapshot = ready_snapshot(temporary.path()).await; let prepared = prepare_publication(&snapshot, &signing_key()).unwrap(); let studio = Studio::open(temporary.path().join("studio")).unwrap(); let binding = PublicationReviewBinding { @@ -674,10 +683,10 @@ mod tests { } /// Intent creation rejects a prepared archive substituted after final review. - #[test] - fn intent_creation_rejects_reviewed_artifact_substitution() { + #[tokio::test] + async fn intent_creation_rejects_reviewed_artifact_substitution() { let temporary = tempfile::tempdir().unwrap(); - let snapshot = ready_snapshot(temporary.path()); + let snapshot = ready_snapshot(temporary.path()).await; let prepared = prepare_publication(&snapshot, &signing_key()).unwrap(); let mut binding = PublicationReviewBinding { artifact: prepared.binding(), diff --git a/crates/frameshift-client/src/session.rs b/crates/frameshift-client/src/session.rs index 4e299d7..e31968e 100644 --- a/crates/frameshift-client/src/session.rs +++ b/crates/frameshift-client/src/session.rs @@ -535,7 +535,7 @@ impl AuthenticatedSession { } } - /// Build a non-refreshable first-party bearer session with an exact expiry. + /// Build a non-refreshable legacy first-party bearer session with an exact expiry. /// /// # Errors /// @@ -546,14 +546,37 @@ impl AuthenticatedSession { access_token: SecretString, expires_at: u64, ) -> Result { - let token = access_token.expose_secret(); - if token.is_empty() - || token - .chars() - .any(|character| character.is_whitespace() || character.is_control()) + Self::from_first_party_parts(access_token, None, expires_at) + } + + /// Build a refreshable first-party native session with an exact access expiry. + /// + /// # Errors + /// + /// Returns an invalid-configuration error when either opaque token is empty, + /// contains HTTP-header whitespace or controls, or the supplied access expiry + /// is not later than the current Unix timestamp. + pub fn from_first_party_tokens( + access_token: SecretString, + refresh_token: SecretString, + expires_at: u64, + ) -> Result { + Self::from_first_party_parts(access_token, Some(refresh_token), expires_at) + } + + /// Validate first-party token material and construct its provider-neutral session. + fn from_first_party_parts( + access_token: SecretString, + refresh_token: Option, + expires_at: u64, + ) -> Result { + if invalid_first_party_token(&access_token) + || refresh_token + .as_ref() + .is_some_and(invalid_first_party_token) { return Err(SessionError::InvalidConfiguration( - "first-party bearer token failed validation".to_string(), + "first-party token material failed validation".to_string(), )); } let acquired_at = unix_now(); @@ -567,7 +590,7 @@ impl AuthenticatedSession { })?; Ok(Self { access_token, - refresh_token: None, + refresh_token, expires_in: Some(expires_in), scope: None, acquired_at, @@ -592,6 +615,15 @@ impl AuthenticatedSession { } } +/// Reject opaque first-party values that cannot be presented safely. +fn invalid_first_party_token(token: &SecretString) -> bool { + token.expose_secret().is_empty() + || token + .expose_secret() + .chars() + .any(|character| character.is_whitespace() || character.is_control()) +} + /// Build the standard discovery URL beneath the exact issuer path. fn discovery_url(issuer: &Url) -> Result { let mut url = issuer.clone(); @@ -928,6 +960,34 @@ mod tests { assert!(!format!("{session:?}").contains("native-session-token")); } + /// Rotating first-party credentials retain refreshability without debug disclosure. + #[test] + fn builds_refreshable_first_party_session() { + let expires_at = unix_now().saturating_add(120); + let session = AuthenticatedSession::from_first_party_tokens( + SecretString::new("native-access-token".to_string()), + SecretString::new("native-refresh-token".to_string()), + expires_at, + ) + .expect("refreshable first-party session"); + + assert_eq!( + session.access_token().expose_secret(), + "native-access-token" + ); + assert_eq!( + session + .refresh_token() + .expect("refresh token") + .expose_secret(), + "native-refresh-token" + ); + assert!(session.summary().refreshable); + let debug = format!("{session:?}"); + assert!(!debug.contains("native-access-token")); + assert!(!debug.contains("native-refresh-token")); + } + /// Empty, header-unsafe, and expired first-party bearer values fail closed. #[test] fn rejects_invalid_first_party_session_material() { @@ -938,6 +998,12 @@ mod tests { future, ) .is_err()); + assert!(AuthenticatedSession::from_first_party_tokens( + SecretString::new("native-access-token".to_string()), + SecretString::new(token.to_string()), + future, + ) + .is_err()); } assert!(AuthenticatedSession::from_first_party_bearer( SecretString::new("native-session-token".to_string()), diff --git a/crates/frameshift-client/src/session_store.rs b/crates/frameshift-client/src/session_store.rs index 237fe79..1162aef 100644 --- a/crates/frameshift-client/src/session_store.rs +++ b/crates/frameshift-client/src/session_store.rs @@ -490,10 +490,10 @@ fn validate_session_authentication( ) -> Result<(), SessionStoreError> { let summary = session.summary(); if matches!(authentication, SessionAuthentication::FirstParty) - && (summary.refreshable || summary.scope.is_some() || summary.expires_in.is_none()) + && (summary.scope.is_some() || summary.expires_in.is_none()) { return Err(SessionStoreError::Invalid( - "first-party sessions must have an expiry and no OIDC refresh metadata".to_string(), + "first-party sessions must have an expiry and no OIDC scope metadata".to_string(), )); } Ok(()) @@ -945,7 +945,7 @@ mod tests { assert!(!store.metadata_path().exists()); } - /// First-party sessions use explicit metadata and remain non-refreshable. + /// First-party sessions keep rotating refresh credentials only in secret storage. #[test] fn saves_and_loads_first_party_session() { let temp = tempfile::tempdir().expect("tempdir"); @@ -957,7 +957,7 @@ mod tests { }; let session = AuthenticatedSession::from_stored_parts( SecretString::new("local-access".to_string()), - None, + Some(SecretString::new("local-refresh".to_string())), Some(600), None, 42, @@ -970,19 +970,26 @@ mod tests { fs::read_to_string(store.metadata_path()).expect("read first-party metadata"); assert!(metadata_text.contains("\"kind\": \"first_party\"")); assert!(!metadata_text.contains("local-access")); + assert!(!metadata_text.contains("local-refresh")); let loaded = store.load_with_store(&credentials).expect("load session"); assert_eq!( loaded.metadata.authentication, SessionAuthentication::FirstParty ); - assert!(loaded.session.refresh_token().is_none()); + assert_eq!( + loaded + .session + .refresh_token() + .map(|token| token.expose_secret().as_str()), + Some("local-refresh") + ); assert_eq!( loaded.session.access_token().expose_secret(), "local-access" ); } - /// Provider metadata cannot relabel an OIDC refreshable session as first-party. + /// Provider metadata cannot relabel an OIDC-scoped session as first-party. #[test] fn rejects_first_party_metadata_for_oidc_session_shape() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/frameshift-server/Cargo.toml b/crates/frameshift-server/Cargo.toml index 67d4666..4e6f5e2 100644 --- a/crates/frameshift-server/Cargo.toml +++ b/crates/frameshift-server/Cargo.toml @@ -47,7 +47,9 @@ flate2 = "1" hmac = { workspace = true } sha2 = { workspace = true } argon2 = { workspace = true } +chacha20poly1305 = { workspace = true } rand_core = { workspace = true } +zeroize = { workspace = true } tower_governor = "0.8" governor = "0.10" # default-features disabled to drop the unmaintained protobuf 2.x dependency diff --git a/crates/frameshift-server/src/config.rs b/crates/frameshift-server/src/config.rs index e90253f..9421f8c 100644 --- a/crates/frameshift-server/src/config.rs +++ b/crates/frameshift-server/src/config.rs @@ -67,10 +67,28 @@ //! | `LOCAL_AUTH_PREVIOUS_PEPPERS` | `""` | Comma-separated `version:secret` entries for peppers rotated out of `LOCAL_AUTH_PASSWORD_PEPPER`; lets credentials hashed under an older pepper version keep verifying instead of being permanently locked out by rotation | //! | `LOCAL_AUTH_ISSUER` | FrameShift first-party URL | Stable issuer written to local account rows | //! | `LOCAL_AUTH_COOKIE_NAME` | `__Host-frameshift_session` | Secure browser session cookie name | +//! | `LOCAL_AUTH_REFRESH_COOKIE_NAME` | `__Host-frameshift_refresh` | Secure browser refresh cookie name | +//! | `LOCAL_AUTH_ACCESS_TTL_SECS` | `900` | Short-lived access-token lifetime | +//! | `LOCAL_AUTH_REFRESH_TTL_SECS` | `2592000` | Rotating refresh-token generation lifetime | //! | `LOCAL_AUTH_INVITE_TTL_SECS` | `604800` | Lifetime of reviewer-issued invitations | //! | `LOCAL_AUTH_BROWSER_IDLE_SECS` | `604800` | Browser session inactivity lifetime | //! | `LOCAL_AUTH_BEARER_IDLE_SECS` | `2592000` | Desktop and CLI session inactivity lifetime | //! | `LOCAL_AUTH_ABSOLUTE_SECS` | `7776000` | Non-extendable lifetime for every local session | +//! | `LOCAL_AUTH_MFA_ENCRYPTION_KEY` | `""` | Canonical base64url-no-padding 256-bit TOTP encryption key | +//! | `LOCAL_AUTH_MFA_KEY_VERSION` | `1` | Positive TOTP encryption-key version | +//! | `LOCAL_AUTH_MFA_ENROLLMENT_TTL_SECS` | `600` | Pending TOTP enrollment lifetime | +//! | `LOCAL_AUTH_MFA_CHALLENGE_TTL_SECS` | `300` | Password-bound MFA challenge lifetime | +//! | `LOCAL_AUTH_MFA_FRESHNESS_SECS` | `300` | Maximum MFA assurance age on privileged routes | +//! | `LOCAL_AUTH_NATIVE_CODE_TTL_SECS` | `300` | One-time native authorization-code lifetime | +//! | `LOCAL_AUTH_NATIVE_AUTHORIZATION_URL` | `""` | Credential-free HTTPS portal URL ending in `/account/` | +//! | `LOCAL_AUTH_RECOVERY_ENABLED` | `false` | Enable fail-closed first-party password recovery when every remaining recovery setting is valid | +//! | `LOCAL_AUTH_RECOVERY_API_KEY` | `""` | Dedicated Resend sending key supplied by the credential broker | +//! | `LOCAL_AUTH_RECOVERY_FROM` | `""` | Verified FrameShift sender identity used for recovery mail | +//! | `LOCAL_AUTH_RECOVERY_RESET_URL` | `""` | HTTPS marketplace recovery page URL; reset bearers are appended only as URL fragments | +//! | `LOCAL_AUTH_RECOVERY_DELIVERY_KEY` | `""` | Base64url-no-padding encoded 256-bit key used only for delivery-payload AEAD | +//! | `LOCAL_AUTH_RECOVERY_KEY_VERSION` | `1` | Positive key version bound into recovery outbox ciphertext AAD | +//! | `LOCAL_AUTH_RECOVERY_TOKEN_TTL_SECS` | `3600` | Single-use reset-token lifetime, capped at 24 hours | +//! | `LOCAL_AUTH_RECOVERY_COOLDOWN_SECS` | `900` | Minimum interval between reset deliveries for one account | //! //! Env var names match the struct field names verbatim (figment maps //! `download_secret` <-> `DOWNLOAD_SECRET`); shorter aliases would require an @@ -87,6 +105,12 @@ use figment::providers::{Env, Serialized}; use figment::Figment; use secrecy::SecretString; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +/// Maximum lifetime allowed for an emailed password-recovery bearer. +const MAX_RECOVERY_TOKEN_TTL: Duration = Duration::from_secs(24 * 60 * 60); +/// Maximum accepted Resend API credential size before header construction. +const MAX_RECOVERY_PROVIDER_KEY_BYTES: usize = 512; /// Log output format. /// @@ -192,7 +216,132 @@ impl std::fmt::Debug for InviteRequestConfig { } } -/// First-party password, invitation, and session configuration. +/// Fail-closed password-recovery and delivery configuration. +#[derive(Clone)] +pub struct PasswordRecoveryConfig { + /// Whether public recovery endpoints and the delivery worker may operate. + pub enabled: bool, + /// Dedicated Resend API credential supplied by the credential broker. + pub provider_api_key: SecretString, + /// Verified sender identity used for reset and password-change mail. + pub from_address: String, + /// HTTPS marketplace page that consumes a reset token from its URL fragment. + pub reset_url: String, + /// Base64url-no-padding encoded 256-bit XChaCha20-Poly1305 key. + pub delivery_key: SecretString, + /// Positive version bound into encrypted delivery-payload AAD. + pub key_version: i16, + /// Exclusive lifetime of a single-use password-reset token. + pub token_ttl: Duration, + /// Minimum interval between reset deliveries for one account. + pub request_cooldown: Duration, +} + +/// Constructors and strict validation for password-recovery configuration. +impl PasswordRecoveryConfig { + /// Return a disabled configuration suitable for tests and deployments without mail. + pub fn disabled() -> Self { + Self { + enabled: false, + provider_api_key: SecretString::new(String::new()), + from_address: String::new(), + reset_url: String::new(), + delivery_key: SecretString::new(String::new()), + key_version: 1, + token_ttl: Duration::from_secs(60 * 60), + request_cooldown: Duration::from_secs(15 * 60), + } + } + + /// Validate all enabled settings and decode the active 256-bit delivery key. + pub fn decoded_delivery_key(&self) -> Result, String> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + use secrecy::ExposeSecret as _; + + if !self.enabled { + return Ok(None); + } + let provider_api_key = self.provider_api_key.expose_secret(); + if provider_api_key.trim().is_empty() + || provider_api_key.trim() != provider_api_key + || provider_api_key.len() > MAX_RECOVERY_PROVIDER_KEY_BYTES + || provider_api_key.chars().any(char::is_control) + { + return Err( + "LOCAL_AUTH_RECOVERY_API_KEY must be a bounded canonical credential".into(), + ); + } + if self.from_address.trim().is_empty() + || self.from_address.trim() != self.from_address + || !self.from_address.contains('@') + || self.from_address.len() > 320 + || self.from_address.chars().any(char::is_control) + { + return Err("LOCAL_AUTH_RECOVERY_FROM must be a bounded sender identity".into()); + } + let reset_url = url::Url::parse(&self.reset_url) + .map_err(|error| format!("LOCAL_AUTH_RECOVERY_RESET_URL is invalid: {error}"))?; + if reset_url.scheme() != "https" + || reset_url.host_str().is_none() + || !reset_url.username().is_empty() + || reset_url.password().is_some() + || reset_url.query().is_some() + || reset_url.fragment().is_some() + { + return Err( + "LOCAL_AUTH_RECOVERY_RESET_URL must be an HTTPS URL without credentials, query, or fragment" + .into(), + ); + } + if self.key_version <= 0 { + return Err("LOCAL_AUTH_RECOVERY_KEY_VERSION must be positive".into()); + } + if self.token_ttl.is_zero() || self.token_ttl > MAX_RECOVERY_TOKEN_TTL { + return Err("LOCAL_AUTH_RECOVERY_TOKEN_TTL_SECS must be between 1 and 86400".into()); + } + if self.request_cooldown.is_zero() || self.request_cooldown > self.token_ttl { + return Err( + "LOCAL_AUTH_RECOVERY_COOLDOWN_SECS must be positive and no greater than the token TTL" + .into(), + ); + } + + let encoded_key = self.delivery_key.expose_secret(); + let decoded = Zeroizing::new(URL_SAFE_NO_PAD.decode(encoded_key).map_err(|error| { + format!("LOCAL_AUTH_RECOVERY_DELIVERY_KEY base64url decode failed: {error}") + })?); + if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded.as_slice()) != encoded_key.as_str() + { + return Err( + "LOCAL_AUTH_RECOVERY_DELIVERY_KEY must be canonical base64url for exactly 32 bytes" + .into(), + ); + } + let mut key = [0_u8; 32]; + key.copy_from_slice(decoded.as_slice()); + Ok(Some(key)) + } +} + +/// Redacted formatting for password-recovery configuration. +impl std::fmt::Debug for PasswordRecoveryConfig { + /// Format non-secret recovery settings while replacing both credentials with markers. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PasswordRecoveryConfig") + .field("enabled", &self.enabled) + .field("provider_api_key", &"[REDACTED]") + .field("from_address", &self.from_address) + .field("reset_url", &self.reset_url) + .field("delivery_key", &"[REDACTED]") + .field("key_version", &self.key_version) + .field("token_ttl", &self.token_ttl) + .field("request_cooldown", &self.request_cooldown) + .finish() + } +} + +/// First-party password, invitation, session, and recovery configuration. #[derive(Clone)] pub struct FirstPartyAuthConfig { /// Deployment pepper supplied by the credential broker. @@ -214,6 +363,12 @@ pub struct FirstPartyAuthConfig { pub issuer: String, /// Secure browser session cookie name. pub cookie_name: String, + /// Secure browser refresh cookie name. + pub refresh_cookie_name: String, + /// Short lifetime of each access token. + pub access_ttl: Duration, + /// Lifetime of each rotating refresh-token generation. + pub refresh_ttl: Duration, /// Lifetime of reviewer-issued invitation tokens. pub invite_ttl: Duration, /// Sliding inactivity lifetime for browser sessions. @@ -222,6 +377,22 @@ pub struct FirstPartyAuthConfig { pub bearer_idle_ttl: Duration, /// Non-extendable maximum session lifetime. pub absolute_ttl: Duration, + /// Base64url-no-padding encoded 256-bit TOTP encryption key. + pub mfa_encryption_key: SecretString, + /// Positive encryption-key version persisted beside TOTP ciphertext. + pub mfa_key_version: i16, + /// Exclusive lifetime of a pending TOTP enrollment. + pub mfa_enrollment_ttl: Duration, + /// Exclusive lifetime of a password-bound MFA challenge. + pub mfa_challenge_ttl: Duration, + /// Maximum age of MFA assurance on privileged account surfaces. + pub mfa_freshness_ttl: Duration, + /// Exclusive lifetime of a native authorization code. + pub native_code_ttl: Duration, + /// Credential-free HTTPS account portal used by native clients. + pub native_authorization_url: String, + /// Optional password-recovery and mail-delivery settings. + pub recovery: PasswordRecoveryConfig, } /// Constructors and readiness checks for first-party account authentication. @@ -234,27 +405,100 @@ impl FirstPartyAuthConfig { previous_peppers: HashMap::new(), issuer: "https://frameshift.syntheos.dev/first-party".to_string(), cookie_name: "__Host-frameshift_session".to_string(), + refresh_cookie_name: "__Host-frameshift_refresh".to_string(), + access_ttl: Duration::from_secs(15 * 60), + refresh_ttl: Duration::from_secs(30 * 24 * 60 * 60), invite_ttl: Duration::from_secs(7 * 24 * 60 * 60), browser_idle_ttl: Duration::from_secs(7 * 24 * 60 * 60), bearer_idle_ttl: Duration::from_secs(30 * 24 * 60 * 60), absolute_ttl: Duration::from_secs(90 * 24 * 60 * 60), + mfa_encryption_key: SecretString::new(String::new()), + mfa_key_version: 1, + mfa_enrollment_ttl: Duration::from_secs(10 * 60), + mfa_challenge_ttl: Duration::from_secs(5 * 60), + mfa_freshness_ttl: Duration::from_secs(5 * 60), + native_code_ttl: Duration::from_secs(5 * 60), + native_authorization_url: String::new(), + recovery: PasswordRecoveryConfig::disabled(), } } - /// Return whether the password pepper and all bounded settings are valid. - pub fn enabled(&self) -> bool { + /// Decode and strictly validate the enabled first-party authentication settings. + pub fn decoded_mfa_encryption_key(&self) -> Result, String> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; use secrecy::ExposeSecret as _; - !self.password_pepper.expose_secret().is_empty() - && self.pepper_version > 0 - && !self.issuer.trim().is_empty() - && self.cookie_name.starts_with("__Host-") - && !self.cookie_name.contains([';', ' ', '\t', '\r', '\n']) - && !self.invite_ttl.is_zero() - && !self.browser_idle_ttl.is_zero() - && !self.bearer_idle_ttl.is_zero() - && self.absolute_ttl >= self.browser_idle_ttl - && self.absolute_ttl >= self.bearer_idle_ttl + if self.password_pepper.expose_secret().is_empty() { + return Ok(None); + } + let valid_cookie_name = |name: &str| { + name.starts_with("__Host-") + && !name.contains([';', ' ', '\t', '\r', '\n']) + && name.len() <= 128 + }; + if self.pepper_version <= 0 + || self.issuer.trim().is_empty() + || !valid_cookie_name(&self.cookie_name) + || !valid_cookie_name(&self.refresh_cookie_name) + || self.cookie_name == self.refresh_cookie_name + || self.access_ttl.is_zero() + || self.refresh_ttl.is_zero() + || self.invite_ttl.is_zero() + || self.browser_idle_ttl.is_zero() + || self.bearer_idle_ttl.is_zero() + || self.absolute_ttl < self.browser_idle_ttl + || self.absolute_ttl < self.bearer_idle_ttl + || self.absolute_ttl < self.access_ttl + || self.absolute_ttl < self.refresh_ttl + || self.mfa_enrollment_ttl.is_zero() + || self.mfa_challenge_ttl.is_zero() + || self.mfa_freshness_ttl.is_zero() + || self.native_code_ttl.is_zero() + || self.mfa_key_version <= 0 + { + return Err( + "first-party authentication durations, versions, or cookie names are invalid" + .into(), + ); + } + let portal = url::Url::parse(&self.native_authorization_url) + .map_err(|error| format!("LOCAL_AUTH_NATIVE_AUTHORIZATION_URL is invalid: {error}"))?; + if portal.scheme() != "https" + || portal.host_str().is_none() + || !portal.username().is_empty() + || portal.password().is_some() + || portal.path() != "/account/" + || portal.query().is_some() + || portal.fragment().is_some() + { + return Err("LOCAL_AUTH_NATIVE_AUTHORIZATION_URL must be a credential-free HTTPS URL whose path is exactly /account/".into()); + } + let encoded_key = self.mfa_encryption_key.expose_secret(); + let decoded = Zeroizing::new(URL_SAFE_NO_PAD.decode(encoded_key).map_err(|error| { + format!("LOCAL_AUTH_MFA_ENCRYPTION_KEY base64url decode failed: {error}") + })?); + if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded.as_slice()) != encoded_key.as_str() + { + return Err( + "LOCAL_AUTH_MFA_ENCRYPTION_KEY must be canonical base64url for exactly 32 bytes" + .into(), + ); + } + let mut key = [0_u8; 32]; + key.copy_from_slice(decoded.as_slice()); + Ok(Some(key)) + } + + /// Return the trusted native account portal when first-party auth is valid. + pub fn native_authorization_url(&self) -> Option { + self.enabled() + .then(|| self.native_authorization_url.clone()) + } + + /// Return whether the password pepper and all bounded settings are valid. + pub fn enabled(&self) -> bool { + matches!(self.decoded_mfa_encryption_key(), Ok(Some(_))) } } @@ -271,10 +515,21 @@ impl std::fmt::Debug for FirstPartyAuthConfig { ) .field("issuer", &self.issuer) .field("cookie_name", &self.cookie_name) + .field("refresh_cookie_name", &self.refresh_cookie_name) + .field("access_ttl", &self.access_ttl) + .field("refresh_ttl", &self.refresh_ttl) .field("invite_ttl", &self.invite_ttl) .field("browser_idle_ttl", &self.browser_idle_ttl) .field("bearer_idle_ttl", &self.bearer_idle_ttl) .field("absolute_ttl", &self.absolute_ttl) + .field("mfa_encryption_key", &"[REDACTED]") + .field("mfa_key_version", &self.mfa_key_version) + .field("mfa_enrollment_ttl", &self.mfa_enrollment_ttl) + .field("mfa_challenge_ttl", &self.mfa_challenge_ttl) + .field("mfa_freshness_ttl", &self.mfa_freshness_ttl) + .field("native_code_ttl", &self.native_code_ttl) + .field("native_authorization_url", &self.native_authorization_url) + .field("recovery", &self.recovery) .finish() } } @@ -599,6 +854,32 @@ impl ServerConfig { out.copy_from_slice(&bytes); Ok(Some(out)) } + + /// Validate enabled recovery against first-party auth and the trusted web origin. + pub fn password_recovery_key(&self) -> Result, String> { + let key = self.first_party_auth.recovery.decoded_delivery_key()?; + if key.is_none() { + return Ok(None); + } + if !self.first_party_auth.enabled() { + return Err( + "LOCAL_AUTH_RECOVERY_ENABLED requires valid first-party authentication".into(), + ); + } + let reset_url = url::Url::parse(&self.first_party_auth.recovery.reset_url) + .map_err(|error| format!("LOCAL_AUTH_RECOVERY_RESET_URL is invalid: {error}"))?; + let reset_origin = reset_url.origin().ascii_serialization(); + if !self + .cors_origins() + .any(|configured| configured == reset_origin) + { + return Err( + "LOCAL_AUTH_RECOVERY_RESET_URL origin must appear exactly in CORS_ALLOWED_ORIGINS" + .into(), + ); + } + Ok(key) + } } /// Manual `Debug` implementation that redacts `postgres_url`. @@ -839,6 +1120,12 @@ struct RawConfig { local_auth_issuer: String, /// Secure browser session cookie name. local_auth_cookie_name: String, + /// Secure browser refresh cookie name. + local_auth_refresh_cookie_name: String, + /// Access-token lifetime in seconds. + local_auth_access_ttl_secs: u64, + /// Refresh-token generation lifetime in seconds. + local_auth_refresh_ttl_secs: u64, /// Reviewer-issued invitation lifetime in seconds. local_auth_invite_ttl_secs: u64, /// Browser session inactivity lifetime in seconds. @@ -847,6 +1134,36 @@ struct RawConfig { local_auth_bearer_idle_secs: u64, /// Non-extendable session lifetime in seconds. local_auth_absolute_secs: u64, + /// Canonical base64url-no-padding TOTP encryption key. + local_auth_mfa_encryption_key: String, + /// Positive TOTP encryption-key version. + local_auth_mfa_key_version: i16, + /// Pending TOTP enrollment lifetime in seconds. + local_auth_mfa_enrollment_ttl_secs: u64, + /// Password-bound MFA challenge lifetime in seconds. + local_auth_mfa_challenge_ttl_secs: u64, + /// MFA assurance freshness lifetime in seconds. + local_auth_mfa_freshness_secs: u64, + /// Native authorization-code lifetime in seconds. + local_auth_native_code_ttl_secs: u64, + /// Credential-free HTTPS native account portal. + local_auth_native_authorization_url: String, + /// Whether the complete password-recovery subsystem is enabled. + local_auth_recovery_enabled: bool, + /// Dedicated Resend API credential for recovery mail. + local_auth_recovery_api_key: String, + /// Verified sender identity for recovery mail. + local_auth_recovery_from: String, + /// HTTPS marketplace page that consumes reset-token fragments. + local_auth_recovery_reset_url: String, + /// Base64url-no-padding encoded 256-bit delivery-encryption key. + local_auth_recovery_delivery_key: String, + /// Positive delivery-encryption key version. + local_auth_recovery_key_version: i16, + /// Single-use reset-token lifetime in seconds. + local_auth_recovery_token_ttl_secs: u64, + /// Minimum interval between reset deliveries for one account. + local_auth_recovery_cooldown_secs: u64, /// Memory backend selector. memory_backend: String, @@ -987,10 +1304,30 @@ impl RawConfig { previous_peppers: parse_previous_peppers(&self.local_auth_previous_peppers), issuer: self.local_auth_issuer, cookie_name: self.local_auth_cookie_name, + refresh_cookie_name: self.local_auth_refresh_cookie_name, + access_ttl: Duration::from_secs(self.local_auth_access_ttl_secs), + refresh_ttl: Duration::from_secs(self.local_auth_refresh_ttl_secs), invite_ttl: Duration::from_secs(self.local_auth_invite_ttl_secs), browser_idle_ttl: Duration::from_secs(self.local_auth_browser_idle_secs), bearer_idle_ttl: Duration::from_secs(self.local_auth_bearer_idle_secs), absolute_ttl: Duration::from_secs(self.local_auth_absolute_secs), + mfa_encryption_key: SecretString::new(self.local_auth_mfa_encryption_key), + mfa_key_version: self.local_auth_mfa_key_version, + mfa_enrollment_ttl: Duration::from_secs(self.local_auth_mfa_enrollment_ttl_secs), + mfa_challenge_ttl: Duration::from_secs(self.local_auth_mfa_challenge_ttl_secs), + mfa_freshness_ttl: Duration::from_secs(self.local_auth_mfa_freshness_secs), + native_code_ttl: Duration::from_secs(self.local_auth_native_code_ttl_secs), + native_authorization_url: self.local_auth_native_authorization_url, + recovery: PasswordRecoveryConfig { + enabled: self.local_auth_recovery_enabled, + provider_api_key: SecretString::new(self.local_auth_recovery_api_key), + from_address: self.local_auth_recovery_from, + reset_url: self.local_auth_recovery_reset_url, + delivery_key: SecretString::new(self.local_auth_recovery_delivery_key), + key_version: self.local_auth_recovery_key_version, + token_ttl: Duration::from_secs(self.local_auth_recovery_token_ttl_secs), + request_cooldown: Duration::from_secs(self.local_auth_recovery_cooldown_secs), + }, }, memory_backend: self.memory_backend, memory_http_endpoint: self.memory_http_endpoint, @@ -1067,10 +1404,28 @@ fn default_raw_config() -> RawConfig { local_auth_previous_peppers: String::new(), local_auth_issuer: "https://frameshift.syntheos.dev/first-party".to_string(), local_auth_cookie_name: "__Host-frameshift_session".to_string(), + local_auth_refresh_cookie_name: "__Host-frameshift_refresh".to_string(), + local_auth_access_ttl_secs: 15 * 60, + local_auth_refresh_ttl_secs: 30 * 24 * 60 * 60, local_auth_invite_ttl_secs: 7 * 24 * 60 * 60, local_auth_browser_idle_secs: 7 * 24 * 60 * 60, local_auth_bearer_idle_secs: 30 * 24 * 60 * 60, local_auth_absolute_secs: 90 * 24 * 60 * 60, + local_auth_mfa_encryption_key: String::new(), + local_auth_mfa_key_version: 1, + local_auth_mfa_enrollment_ttl_secs: 10 * 60, + local_auth_mfa_challenge_ttl_secs: 5 * 60, + local_auth_mfa_freshness_secs: 5 * 60, + local_auth_native_code_ttl_secs: 5 * 60, + local_auth_native_authorization_url: String::new(), + local_auth_recovery_enabled: false, + local_auth_recovery_api_key: String::new(), + local_auth_recovery_from: String::new(), + local_auth_recovery_reset_url: String::new(), + local_auth_recovery_delivery_key: String::new(), + local_auth_recovery_key_version: 1, + local_auth_recovery_token_ttl_secs: 60 * 60, + local_auth_recovery_cooldown_secs: 15 * 60, memory_backend: "none".to_string(), memory_http_endpoint: String::new(), memory_http_auth: "none".to_string(), @@ -1364,6 +1719,114 @@ mod tests { ); } + #[test] + /// Password recovery defaults off without requiring provider or encryption secrets. + fn password_recovery_defaults_disabled() { + let config = default_raw_config().into_server_config(); + assert!(matches!(config.password_recovery_key(), Ok(None))); + } + + #[test] + /// Complete recovery settings decode one canonical key and match the trusted origin. + fn password_recovery_accepts_complete_configuration() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + + let mut raw = default_raw_config(); + raw.cors_allowed_origins = "https://frameshift.test".to_string(); + raw.local_auth_password_pepper = "test-password-pepper".to_string(); + raw.local_auth_mfa_encryption_key = URL_SAFE_NO_PAD.encode([11_u8; 32]); + raw.local_auth_native_authorization_url = "https://frameshift.test/account/".to_string(); + raw.local_auth_recovery_enabled = true; + raw.local_auth_recovery_api_key = "re_test_provider_key".to_string(); + raw.local_auth_recovery_from = "FrameShift ".to_string(); + raw.local_auth_recovery_reset_url = "https://frameshift.test/recover/".to_string(); + raw.local_auth_recovery_delivery_key = URL_SAFE_NO_PAD.encode([7_u8; 32]); + let config = raw.into_server_config(); + + assert_eq!( + config + .password_recovery_key() + .expect("complete recovery configuration") + .expect("recovery enabled"), + [7_u8; 32] + ); + } + + #[test] + /// Enabled recovery rejects missing secrets, untrusted origins, and excessive token TTLs. + fn password_recovery_rejects_partial_or_unsafe_configuration() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + + let mut partial = default_raw_config(); + partial.local_auth_recovery_enabled = true; + assert!(partial + .into_server_config() + .password_recovery_key() + .is_err()); + + let mut unsafe_config = default_raw_config(); + unsafe_config.cors_allowed_origins = "https://other.frameshift.test".to_string(); + unsafe_config.local_auth_password_pepper = "test-password-pepper".to_string(); + unsafe_config.local_auth_recovery_enabled = true; + unsafe_config.local_auth_recovery_api_key = "re_test_provider_key".to_string(); + unsafe_config.local_auth_recovery_from = + "FrameShift ".to_string(); + unsafe_config.local_auth_recovery_reset_url = + "https://frameshift.test/recover/".to_string(); + unsafe_config.local_auth_recovery_delivery_key = URL_SAFE_NO_PAD.encode([9_u8; 32]); + unsafe_config.local_auth_recovery_token_ttl_secs = 24 * 60 * 60 + 1; + assert!(unsafe_config + .into_server_config() + .password_recovery_key() + .is_err()); + + let mut padded_provider_key = default_raw_config(); + padded_provider_key.cors_allowed_origins = "https://frameshift.test".to_string(); + padded_provider_key.local_auth_password_pepper = "test-password-pepper".to_string(); + padded_provider_key.local_auth_recovery_enabled = true; + padded_provider_key.local_auth_recovery_api_key = " re_test_provider_key".to_string(); + padded_provider_key.local_auth_recovery_from = + "FrameShift ".to_string(); + padded_provider_key.local_auth_recovery_reset_url = + "https://frameshift.test/recover/".to_string(); + padded_provider_key.local_auth_recovery_delivery_key = URL_SAFE_NO_PAD.encode([9_u8; 32]); + assert!(padded_provider_key + .into_server_config() + .password_recovery_key() + .is_err()); + + let mut padded_delivery_key = default_raw_config(); + padded_delivery_key.cors_allowed_origins = "https://frameshift.test".to_string(); + padded_delivery_key.local_auth_password_pepper = "test-password-pepper".to_string(); + padded_delivery_key.local_auth_recovery_enabled = true; + padded_delivery_key.local_auth_recovery_api_key = "re_test_provider_key".to_string(); + padded_delivery_key.local_auth_recovery_from = + "FrameShift ".to_string(); + padded_delivery_key.local_auth_recovery_reset_url = + "https://frameshift.test/recover/".to_string(); + padded_delivery_key.local_auth_recovery_delivery_key = + format!(" {}", URL_SAFE_NO_PAD.encode([9_u8; 32])); + assert!(padded_delivery_key + .into_server_config() + .password_recovery_key() + .is_err()); + } + + #[test] + /// Recovery Debug formatting never exposes provider or delivery-key material. + fn password_recovery_debug_redacts_secrets() { + let mut recovery = PasswordRecoveryConfig::disabled(); + recovery.provider_api_key = SecretString::new("RAW_RECOVERY_API_KEY".to_string()); + recovery.delivery_key = SecretString::new("RAW_RECOVERY_DELIVERY_KEY".to_string()); + let debug = format!("{recovery:?}"); + + assert!(!debug.contains("RAW_RECOVERY_API_KEY")); + assert!(!debug.contains("RAW_RECOVERY_DELIVERY_KEY")); + assert!(debug.contains("[REDACTED]")); + } + /// Build a [`ServerConfig`] populated with test-friendly defaults and the /// given `download_secret`. fn make_test_cfg(secret: &str) -> ServerConfig { diff --git a/crates/frameshift-server/src/first_party_auth.rs b/crates/frameshift-server/src/first_party_auth.rs new file mode 100644 index 0000000..9a2a53d --- /dev/null +++ b/crates/frameshift-server/src/first_party_auth.rs @@ -0,0 +1,687 @@ +//! Cryptographic and lifecycle helpers for first-party authentication. + +use std::net::IpAddr; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use chacha20poly1305::aead::{Aead as _, KeyInit as _, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use chrono::{DateTime, Duration, Utc}; +use frameshift_catalog::{ + AccountAuthAuditEventKind, AccountAuthAuditEventRecord, AccountAuthAuditOutcome, + AccountMfaRecoveryCodeSeed, AccountSessionClientKind, AccountSessionIssuance, + AccountSessionRecord, EncryptedTotpSecret, +}; +use hmac::{Hmac, Mac as _}; +use rand_core::{OsRng, RngCore as _}; +use secrecy::ExposeSecret as _; +use sha2::{Digest as _, Sha256}; +use url::{Host, Url}; +use uuid::Uuid; +use zeroize::{Zeroize as _, Zeroizing}; + +use crate::config::FirstPartyAuthConfig; +use crate::error::AppError; + +/// SHA-256 HMAC used for TOTP and deployment-keyed audit tags. +type HmacSha256 = Hmac; + +/// Raw byte length of an access token. +const ACCESS_TOKEN_BYTES: usize = 32; +/// Raw random portion of bound one-time tokens. +const BOUND_TOKEN_RANDOM_BYTES: usize = 32; +/// Raw byte length of a bound one-time token. +const BOUND_TOKEN_BYTES: usize = 16 + 1 + BOUND_TOKEN_RANDOM_BYTES; +/// Raw byte length of a native code carrying inherited MFA assurance. +const NATIVE_CODE_TOKEN_BYTES: usize = 16 + 1 + 8 + BOUND_TOKEN_RANDOM_BYTES; +/// Raw byte length of a self-describing refresh token. +const REFRESH_TOKEN_BYTES: usize = 16 + 16 + 8 + 1 + 32; +/// TOTP timestep duration in seconds. +const TOTP_STEP_SECONDS: i64 = 30; +/// Number of decimal digits emitted by a TOTP code. +const TOTP_DIGITS: u32 = 6; +/// Number of recovery codes issued at activation. +pub(crate) const MFA_RECOVERY_CODE_COUNT: usize = 10; + +/// Plaintext credentials and digest-only session issuance created together. +pub(crate) struct IssuedSession { + /// Digest-only session family persisted by the catalog. + pub(crate) issuance: AccountSessionIssuance, + /// Random access token returned exactly once. + pub(crate) access_token: String, + /// Random refresh token returned exactly once. + pub(crate) refresh_token: String, +} + +/// Validated self-describing refresh token metadata. +pub(crate) struct DecodedRefreshToken { + /// SHA-256 digest used for the catalog lookup. + pub(crate) digest: Vec, + /// Account identifier authenticated into the token digest. + pub(crate) account_id: Uuid, + /// Session-family identifier authenticated into the token digest. + pub(crate) session_id: Uuid, + /// Non-extendable session-family expiry authenticated into the token digest. + pub(crate) absolute_expires_at: DateTime, + /// Client class authenticated into the token digest. + pub(crate) client_kind: AccountSessionClientKind, +} + +/// Validated self-describing one-time token metadata. +pub(crate) struct DecodedBoundToken { + /// SHA-256 digest used for the catalog lookup. + pub(crate) digest: Vec, + /// Account identifier authenticated into the token digest. + pub(crate) account_id: Uuid, + /// Client class authenticated into the token digest. + pub(crate) client_kind: AccountSessionClientKind, +} + +/// Validated self-describing native authorization code metadata. +pub(crate) struct DecodedNativeCode { + /// SHA-256 digest used for one-time catalog exchange. + pub(crate) digest: Vec, + /// Account identifier authenticated into the code digest. + pub(crate) account_id: Uuid, + /// Desktop or CLI class authenticated into the code digest. + pub(crate) client_kind: AccountSessionClientKind, + /// Browser MFA assurance inherited by the future native session. + pub(crate) mfa_verified_at: DateTime, +} + +/// Deployment-keyed TOTP secret cipher. +pub(crate) struct MfaSecretCipher { + /// Raw XChaCha20-Poly1305 key cleared when the helper is dropped. + key: [u8; 32], + /// Version persisted beside every encrypted secret. + key_version: i16, +} + +/// Secure cleanup for the in-memory MFA encryption key. +impl Drop for MfaSecretCipher { + /// Clear the raw deployment key before releasing its memory. + fn drop(&mut self) { + self.key.zeroize(); + } +} + +/// Encryption and decryption operations for TOTP seeds. +impl MfaSecretCipher { + /// Construct a cipher only from a fully valid enabled configuration. + pub(crate) fn from_config(config: &FirstPartyAuthConfig) -> Result { + let key = config + .decoded_mfa_encryption_key() + .map_err(|_| AppError::Internal("first-party MFA configuration is invalid".into()))? + .ok_or_else(|| AppError::NotFound("first-party account routes are disabled".into()))?; + Ok(Self { + key, + key_version: config.mfa_key_version, + }) + } + + /// Encrypt one TOTP seed with account- and authenticator-bound AAD. + pub(crate) fn encrypt( + &self, + account_id: Uuid, + authenticator_id: Uuid, + secret: &[u8], + ) -> Result { + let cipher = XChaCha20Poly1305::new((&self.key).into()); + let mut nonce = [0_u8; 24]; + OsRng.fill_bytes(&mut nonce); + let aad = mfa_aad(account_id, authenticator_id, self.key_version); + let ciphertext = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: secret, + aad: &aad, + }, + ) + .map_err(|_| AppError::Internal("MFA secret encryption failed".into()))?; + Ok(EncryptedTotpSecret { + ciphertext, + nonce, + key_version: self.key_version, + }) + } + + /// Decrypt one TOTP seed only under its exact owner and authenticator AAD. + pub(crate) fn decrypt( + &self, + account_id: Uuid, + authenticator_id: Uuid, + secret: &EncryptedTotpSecret, + ) -> Result>, AppError> { + if secret.key_version != self.key_version { + return Err(AppError::ServiceUnavailable( + "MFA encryption key version is unavailable".into(), + )); + } + let cipher = XChaCha20Poly1305::new((&self.key).into()); + let aad = mfa_aad(account_id, authenticator_id, secret.key_version); + cipher + .decrypt( + XNonce::from_slice(&secret.nonce), + Payload { + msg: &secret.ciphertext, + aad: &aad, + }, + ) + .map(Zeroizing::new) + .map_err(|_| AppError::Internal("MFA secret decryption failed".into())) + } +} + +/// Generate a random canonical access token and its digest. +pub(crate) fn generate_access_token() -> (String, Vec) { + generate_random_token(ACCESS_TOKEN_BYTES) +} + +/// Decode one canonical access token and return its digest. +pub(crate) fn decode_access_token(token: &str) -> Result, AppError> { + let raw = decode_canonical_token(token, ACCESS_TOKEN_BYTES)?; + Ok(Sha256::digest(raw.as_slice()).to_vec()) +} + +/// Build one new session family and both transport credentials. +pub(crate) fn issue_session( + config: &FirstPartyAuthConfig, + account_id: Uuid, + client_kind: AccountSessionClientKind, + now: DateTime, + mfa_verified_at: Option>, +) -> Result { + let session_id = Uuid::new_v4(); + let (access_token, access_digest) = generate_access_token(); + let absolute_expires_at = add_std_duration(now, config.absolute_ttl, "session absolute TTL")?; + let (refresh_token, refresh_digest) = + generate_refresh_token(account_id, session_id, absolute_expires_at, client_kind); + let idle_ttl = match client_kind { + AccountSessionClientKind::Browser => config.browser_idle_ttl, + AccountSessionClientKind::Desktop | AccountSessionClientKind::Cli => config.bearer_idle_ttl, + }; + let idle_expires_at = std::cmp::min( + add_std_duration(now, idle_ttl, "session idle TTL")?, + absolute_expires_at, + ); + let access_expires_at = std::cmp::min( + add_std_duration(now, config.access_ttl, "access-token TTL")?, + absolute_expires_at, + ); + let refresh_expires_at = std::cmp::min( + add_std_duration(now, config.refresh_ttl, "refresh-token TTL")?, + std::cmp::min(idle_expires_at, absolute_expires_at), + ); + Ok(IssuedSession { + issuance: AccountSessionIssuance { + session: AccountSessionRecord { + id: session_id, + account_id, + token_digest: access_digest, + client_kind, + created_at: now, + last_seen_at: now, + access_expires_at, + idle_expires_at, + absolute_expires_at, + mfa_verified_at, + revoked_at: None, + }, + refresh_token_id: Uuid::new_v4(), + refresh_token_digest: refresh_digest, + refresh_expires_at, + }, + access_token, + refresh_token, + }) +} + +/// Generate a refresh token bound to its account and session family. +pub(crate) fn generate_refresh_token( + account_id: Uuid, + session_id: Uuid, + absolute_expires_at: DateTime, + client_kind: AccountSessionClientKind, +) -> (String, Vec) { + let mut raw = Zeroizing::new([0_u8; REFRESH_TOKEN_BYTES]); + raw[..16].copy_from_slice(account_id.as_bytes()); + raw[16..32].copy_from_slice(session_id.as_bytes()); + raw[32..40].copy_from_slice(&absolute_expires_at.timestamp().to_be_bytes()); + raw[40] = encode_client_kind(client_kind); + OsRng.fill_bytes(&mut raw[41..]); + ( + URL_SAFE_NO_PAD.encode(raw.as_slice()), + Sha256::digest(raw.as_slice()).to_vec(), + ) +} + +/// Decode a canonical refresh token and recover its authenticated metadata. +pub(crate) fn decode_refresh_token(token: &str) -> Result { + let raw = decode_canonical_token(token, REFRESH_TOKEN_BYTES)?; + let account_id = Uuid::from_slice(&raw[..16]) + .map_err(|_| AppError::Unauthorized("refresh token is invalid or expired".into()))?; + let session_id = Uuid::from_slice(&raw[16..32]) + .map_err(|_| AppError::Unauthorized("refresh token is invalid or expired".into()))?; + let timestamp = i64::from_be_bytes( + raw[32..40] + .try_into() + .map_err(|_| AppError::Unauthorized("refresh token is invalid or expired".into()))?, + ); + let absolute_expires_at = DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| AppError::Unauthorized("refresh token is invalid or expired".into()))?; + let client_kind = decode_client_kind(raw[40]) + .ok_or_else(|| AppError::Unauthorized("refresh token is invalid or expired".into()))?; + Ok(DecodedRefreshToken { + digest: Sha256::digest(raw.as_slice()).to_vec(), + account_id, + session_id, + absolute_expires_at, + client_kind, + }) +} + +/// Generate a one-time token bound to an account and client class. +pub(crate) fn generate_bound_token( + account_id: Uuid, + client_kind: AccountSessionClientKind, +) -> (String, Vec) { + let mut raw = Zeroizing::new([0_u8; BOUND_TOKEN_BYTES]); + raw[..16].copy_from_slice(account_id.as_bytes()); + raw[16] = encode_client_kind(client_kind); + OsRng.fill_bytes(&mut raw[17..]); + ( + URL_SAFE_NO_PAD.encode(raw.as_slice()), + Sha256::digest(raw.as_slice()).to_vec(), + ) +} + +/// Decode a canonical one-time token and recover its authenticated metadata. +pub(crate) fn decode_bound_token(token: &str) -> Result { + let raw = decode_canonical_token(token, BOUND_TOKEN_BYTES)?; + let account_id = Uuid::from_slice(&raw[..16]) + .map_err(|_| AppError::Unauthorized("token is invalid or expired".into()))?; + let client_kind = decode_client_kind(raw[16]) + .ok_or_else(|| AppError::Unauthorized("token is invalid or expired".into()))?; + Ok(DecodedBoundToken { + digest: Sha256::digest(raw.as_slice()).to_vec(), + account_id, + client_kind, + }) +} + +/// Generate a native authorization code carrying exact inherited MFA assurance. +pub(crate) fn generate_native_code( + account_id: Uuid, + client_kind: AccountSessionClientKind, + mfa_verified_at: DateTime, +) -> (String, Vec, DateTime) { + let timestamp_micros = mfa_verified_at.timestamp_micros(); + let normalized_mfa_verified_at = DateTime::from_timestamp_micros(timestamp_micros) + .expect("an existing UTC timestamp remains representable at microsecond precision"); + let mut raw = Zeroizing::new([0_u8; NATIVE_CODE_TOKEN_BYTES]); + raw[..16].copy_from_slice(account_id.as_bytes()); + raw[16] = encode_client_kind(client_kind); + raw[17..25].copy_from_slice(×tamp_micros.to_be_bytes()); + OsRng.fill_bytes(&mut raw[25..]); + ( + URL_SAFE_NO_PAD.encode(raw.as_slice()), + Sha256::digest(raw.as_slice()).to_vec(), + normalized_mfa_verified_at, + ) +} + +/// Decode one canonical native code and recover its exact assurance binding. +pub(crate) fn decode_native_code(token: &str) -> Result { + let raw = decode_canonical_token(token, NATIVE_CODE_TOKEN_BYTES)?; + let account_id = Uuid::from_slice(&raw[..16]) + .map_err(|_| AppError::Unauthorized("authorization code is invalid or expired".into()))?; + let client_kind = decode_client_kind(raw[16]) + .ok_or_else(|| AppError::Unauthorized("authorization code is invalid or expired".into()))?; + let timestamp_micros = + i64::from_be_bytes(raw[17..25].try_into().map_err(|_| { + AppError::Unauthorized("authorization code is invalid or expired".into()) + })?); + let mfa_verified_at = DateTime::from_timestamp_micros(timestamp_micros) + .ok_or_else(|| AppError::Unauthorized("authorization code is invalid or expired".into()))?; + Ok(DecodedNativeCode { + digest: Sha256::digest(raw.as_slice()).to_vec(), + account_id, + client_kind, + mfa_verified_at, + }) +} + +/// Security-relevant optional bindings carried by one authentication audit event. +#[derive(Default)] +pub(crate) struct AuthAuditContext { + /// Affected account when known without exposing caller-controlled input. + pub account_id: Option, + /// Affected session family when one exists. + pub session_id: Option, + /// Client class involved in the authentication transition. + pub client_kind: Option, + /// Deployment-keyed canonical identifier digest when available. + pub identifier_tag: Option>, + /// Bounded static rejection reason that contains no caller input. + pub reason_code: Option<&'static str>, +} + +/// Construct one bounded sanitized authentication audit event. +pub(crate) fn auth_audit_event( + event_kind: AccountAuthAuditEventKind, + outcome: AccountAuthAuditOutcome, + context: AuthAuditContext, + created_at: DateTime, +) -> AccountAuthAuditEventRecord { + AccountAuthAuditEventRecord { + id: Uuid::new_v4(), + event_kind, + outcome, + account_id: context.account_id, + session_id: context.session_id, + client_kind: context.client_kind, + identifier_tag: context.identifier_tag, + network_tag: None, + reason_code: context.reason_code.map(str::to_string), + created_at, + } +} + +/// Derive a deployment-keyed identifier tag without storing an email address. +pub(crate) fn identifier_tag(config: &FirstPartyAuthConfig, identifier: &str) -> Vec { + let mut mac = ::new_from_slice( + config.password_pepper.expose_secret().as_bytes(), + ) + .expect("HMAC accepts keys of every length"); + mac.update(b"frameshift-auth-identifier-v1\0"); + mac.update(identifier.as_bytes()); + mac.finalize().into_bytes().to_vec() +} + +/// Generate a random TOTP seed held in zeroizing memory. +pub(crate) fn generate_totp_secret() -> Zeroizing> { + let mut secret = Zeroizing::new(vec![0_u8; 32]); + OsRng.fill_bytes(secret.as_mut_slice()); + secret +} + +/// Verify a six-digit HMAC-SHA256 TOTP within a one-step clock window. +pub(crate) fn verify_totp(secret: &[u8], code: &str, now: DateTime) -> Result { + if code.len() != TOTP_DIGITS as usize || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(AppError::Unauthorized( + "MFA proof is invalid or expired".into(), + )); + } + let current = now.timestamp().div_euclid(TOTP_STEP_SECONDS); + for timestep in [current - 1, current, current + 1] { + let expected = totp_code(secret, timestep)?; + if constant_time_eq(expected.as_bytes(), code.as_bytes()) { + return Ok(timestep); + } + } + Err(AppError::Unauthorized( + "MFA proof is invalid or expired".into(), + )) +} + +/// Encode a TOTP seed for display in an enrollment URI. +pub(crate) fn base32_no_pad(secret: &[u8]) -> String { + const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut output = String::with_capacity((secret.len() * 8).div_ceil(5)); + let mut accumulator = 0_u32; + let mut bits = 0_u8; + for byte in secret { + accumulator = (accumulator << 8) | u32::from(*byte); + bits += 8; + while bits >= 5 { + bits -= 5; + output.push(ALPHABET[((accumulator >> bits) & 0x1f) as usize] as char); + } + } + if bits > 0 { + output.push(ALPHABET[((accumulator << (5 - bits)) & 0x1f) as usize] as char); + } + output +} + +/// Generate high-entropy recovery codes and digest-only persistence seeds. +pub(crate) fn generate_recovery_codes( + count: usize, +) -> (Vec, Vec) { + let mut plaintext = Vec::with_capacity(count); + let mut seeds = Vec::with_capacity(count); + for _ in 0..count { + let (code, digest) = generate_random_token(18); + plaintext.push(code); + seeds.push(AccountMfaRecoveryCodeSeed { + id: Uuid::new_v4(), + code_digest: digest, + }); + } + (plaintext, seeds) +} + +/// Decode and digest one canonical recovery code. +pub(crate) fn decode_recovery_code(code: &str) -> Result, AppError> { + let raw = decode_canonical_token(code, 18)?; + Ok(Sha256::digest(raw.as_slice()).to_vec()) +} + +/// Decode an exact canonical S256 PKCE challenge. +pub(crate) fn decode_pkce_challenge(challenge: &str) -> Result, AppError> { + decode_canonical_token(challenge, 32).map(|decoded| decoded.to_vec()) +} + +/// Hash and validate one RFC 7636 high-entropy PKCE verifier. +pub(crate) fn pkce_challenge_for_verifier(verifier: &str) -> Result, AppError> { + if !(43..=128).contains(&verifier.len()) + || !verifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')) + { + return Err(AppError::BadRequest("PKCE verifier is invalid".into())); + } + Ok(Sha256::digest(verifier.as_bytes()).to_vec()) +} + +/// Validate and preserve one exact IP-literal loopback redirect URI. +pub(crate) fn canonical_loopback_redirect(raw: &str) -> Result { + if raw.len() > 2_048 || raw.chars().any(char::is_control) { + return Err(AppError::BadRequest("redirect_uri is invalid".into())); + } + let parsed = + Url::parse(raw).map_err(|_| AppError::BadRequest("redirect_uri is invalid".into()))?; + let loopback = match parsed.host() { + Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), + Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), + Some(Host::Domain(_)) | None => false, + }; + if parsed.scheme() != "http" + || !loopback + || parsed.port().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.as_str() != raw + { + return Err(AppError::BadRequest("redirect_uri is invalid".into())); + } + Ok(raw.to_string()) +} + +/// Validate a canonical bounded OAuth state value before reflection. +pub(crate) fn canonical_oauth_state(state: &str) -> Result { + let decoded = URL_SAFE_NO_PAD + .decode(state) + .map_err(|_| AppError::BadRequest("state is invalid".into()))?; + if !(16..=64).contains(&decoded.len()) || URL_SAFE_NO_PAD.encode(&decoded) != state { + return Err(AppError::BadRequest("state is invalid".into())); + } + Ok(state.to_string()) +} + +/// Append an authorization code and reflected state to a validated loopback URI. +pub(crate) fn authorization_redirect( + redirect_uri: &str, + code: &str, + state: &str, +) -> Result { + let mut redirect = Url::parse(redirect_uri) + .map_err(|_| AppError::Internal("validated redirect URI became invalid".into()))?; + redirect + .query_pairs_mut() + .append_pair("code", code) + .append_pair("state", state); + Ok(redirect.into()) +} + +/// Convert one standard duration into a checked UTC timestamp. +pub(crate) fn add_std_duration( + now: DateTime, + duration: std::time::Duration, + label: &'static str, +) -> Result, AppError> { + let duration = Duration::from_std(duration) + .map_err(|_| AppError::Internal(format!("{label} is invalid")))?; + now.checked_add_signed(duration) + .ok_or_else(|| AppError::Internal(format!("{label} exceeds the timestamp range"))) +} + +/// Build authenticated additional data for one encrypted TOTP seed. +fn mfa_aad(account_id: Uuid, authenticator_id: Uuid, key_version: i16) -> Vec { + format!("frameshift-totp-v1:{account_id}:{authenticator_id}:{key_version}").into_bytes() +} + +/// Generate a random canonical token with the requested byte length. +fn generate_random_token(byte_len: usize) -> (String, Vec) { + let mut raw = Zeroizing::new(vec![0_u8; byte_len]); + OsRng.fill_bytes(raw.as_mut_slice()); + ( + URL_SAFE_NO_PAD.encode(raw.as_slice()), + Sha256::digest(raw.as_slice()).to_vec(), + ) +} + +/// Decode canonical unpadded base64url with an exact raw byte length. +fn decode_canonical_token( + token: &str, + expected_len: usize, +) -> Result>, AppError> { + if token.len() > 256 || token.chars().any(char::is_whitespace) { + return Err(AppError::Unauthorized("token is invalid or expired".into())); + } + let raw = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| AppError::Unauthorized("token is invalid or expired".into()))?, + ); + if raw.len() != expected_len || URL_SAFE_NO_PAD.encode(raw.as_slice()) != token { + return Err(AppError::Unauthorized("token is invalid or expired".into())); + } + Ok(raw) +} + +/// Encode a client class into a stable one-byte token binding. +fn encode_client_kind(client_kind: AccountSessionClientKind) -> u8 { + match client_kind { + AccountSessionClientKind::Browser => 0, + AccountSessionClientKind::Desktop => 1, + AccountSessionClientKind::Cli => 2, + } +} + +/// Decode a stable one-byte client-class token binding. +fn decode_client_kind(value: u8) -> Option { + match value { + 0 => Some(AccountSessionClientKind::Browser), + 1 => Some(AccountSessionClientKind::Desktop), + 2 => Some(AccountSessionClientKind::Cli), + _ => None, + } +} + +/// Compute one six-digit HMAC-SHA256 TOTP code. +fn totp_code(secret: &[u8], timestep: i64) -> Result { + let timestep = u64::try_from(timestep) + .map_err(|_| AppError::Unauthorized("MFA proof is invalid or expired".into()))?; + let mut mac = ::new_from_slice(secret) + .map_err(|_| AppError::Internal("TOTP key is invalid".into()))?; + mac.update(×tep.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + let offset = usize::from(digest[digest.len() - 1] & 0x0f); + let value = (u32::from(digest[offset] & 0x7f) << 24) + | (u32::from(digest[offset + 1]) << 16) + | (u32::from(digest[offset + 2]) << 8) + | u32::from(digest[offset + 3]); + Ok(format!("{:06}", value % 10_u32.pow(TOTP_DIGITS))) +} + +/// Compare equal-length byte strings without data-dependent early return. +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} + +#[cfg(test)] +/// Unit tests for token, redirect, PKCE, and TOTP invariants. +mod tests { + use super::*; + + /// Access tokens require canonical unpadded base64url encoding. + #[test] + fn access_tokens_round_trip_canonically() { + let (token, digest) = generate_access_token(); + assert_eq!(decode_access_token(&token).unwrap(), digest); + assert!(decode_access_token(&format!("{token}=")).is_err()); + } + + /// Refresh tokens retain authenticated account and session identifiers. + #[test] + fn refresh_tokens_round_trip_bindings() { + let account_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let expires_at = Utc::now() + Duration::hours(1); + let (token, digest) = generate_refresh_token( + account_id, + session_id, + expires_at, + AccountSessionClientKind::Cli, + ); + let decoded = decode_refresh_token(&token).unwrap(); + assert_eq!(decoded.account_id, account_id); + assert_eq!(decoded.session_id, session_id); + assert_eq!( + decoded.absolute_expires_at, + DateTime::from_timestamp(expires_at.timestamp(), 0).unwrap() + ); + assert_eq!(decoded.client_kind, AccountSessionClientKind::Cli); + assert_eq!(decoded.digest, digest); + } + + /// Redirect validation accepts only exact IP-literal HTTP loopback URIs. + #[test] + fn redirect_validation_is_loopback_only() { + assert!(canonical_loopback_redirect("http://127.0.0.1:49152/callback").is_ok()); + assert!(canonical_loopback_redirect("http://[::1]:49152/callback").is_ok()); + assert!(canonical_loopback_redirect("http://localhost:49152/callback").is_err()); + assert!(canonical_loopback_redirect("https://127.0.0.1:49152/callback").is_err()); + assert!(canonical_loopback_redirect("http://127.0.0.1:49152/callback?x=1").is_err()); + } + + /// PKCE accepts the RFC unreserved verifier alphabet and rejects short inputs. + #[test] + fn pkce_verifier_is_bounded() { + assert!(pkce_challenge_for_verifier(&"a".repeat(43)).is_ok()); + assert!(pkce_challenge_for_verifier(&"a".repeat(42)).is_err()); + assert!(pkce_challenge_for_verifier(&format!("{}!", "a".repeat(42))).is_err()); + } +} diff --git a/crates/frameshift-server/src/lib.rs b/crates/frameshift-server/src/lib.rs index 47c2e15..723de5d 100644 --- a/crates/frameshift-server/src/lib.rs +++ b/crates/frameshift-server/src/lib.rs @@ -48,11 +48,16 @@ pub mod auth; pub mod config; pub mod download; pub mod error; +/// First-party authentication cryptography and session lifecycle helpers. +mod first_party_auth; pub mod mcp; pub mod metrics; pub mod middleware; pub mod password_auth; +/// Compromised and deployment-specific password rejection data. +mod password_blocklist; pub mod publication; +pub mod recovery_delivery; pub mod router; pub mod routes; pub mod state; @@ -62,7 +67,10 @@ use std::sync::Arc; use frameshift_objects::PackStore; -pub use config::{FirstPartyAuthConfig, InviteRequestConfig, LogFormat, OidcConfig, ServerConfig}; +pub use config::{ + FirstPartyAuthConfig, InviteRequestConfig, LogFormat, OidcConfig, PasswordRecoveryConfig, + ServerConfig, +}; pub use error::AppError; pub use router::{app, app_with_publication_admission}; pub use state::AppState; diff --git a/crates/frameshift-server/src/main.rs b/crates/frameshift-server/src/main.rs index 0ab877c..e0153f1 100644 --- a/crates/frameshift-server/src/main.rs +++ b/crates/frameshift-server/src/main.rs @@ -12,12 +12,17 @@ use mimalloc::MiMalloc; use tracing_subscriber::layer::SubscriberExt as _; use tracing_subscriber::util::SubscriberInitExt as _; +use frameshift_catalog::CatalogBackend; use frameshift_catalog_postgres::{PostgresCatalog, PostgresCatalogConfig}; use frameshift_memory::MemoryAdapter; use frameshift_objects::PackStore; use frameshift_objects_fs::{FsPackStore, FsPackStoreConfig}; use frameshift_objects_r2::{R2PackStore, R2PackStoreConfig}; use frameshift_server::metrics::Metrics; +use frameshift_server::recovery_delivery::{ + run_recovery_delivery_worker, RecoveryDeliveryCipher, RecoveryDeliveryDispatcher, + RecoveryDeliveryWorkerConfig, ResendRecoveryDispatcher, +}; use frameshift_server::{AppState, LogFormat, ServerConfig, ServerError}; /// Delay between bounded signed-request nonce cleanup batches. @@ -67,6 +72,21 @@ fn init_tracing(config: &ServerConfig) { /// runtime failures after the server is already accepting connections. async fn build_state(config: Arc) -> Result { use secrecy::ExposeSecret as _; + use zeroize::Zeroize as _; + + if let Some(mut delivery_key) = config + .password_recovery_key() + .map_err(ServerError::Startup)? + { + delivery_key.zeroize(); + } + if let Some(mut mfa_key) = config + .first_party_auth + .decoded_mfa_encryption_key() + .map_err(ServerError::Startup)? + { + mfa_key.zeroize(); + } let catalog_config = PostgresCatalogConfig { url: secrecy::SecretString::new(config.postgres_url.expose_secret().to_string()), @@ -202,6 +222,11 @@ enum QuarantineMode { R2, } +/// Return whether at least one supported account-authentication mechanism is ready. +fn account_auth_available(oidc_verifier_available: bool, first_party_auth_enabled: bool) -> bool { + oidc_verifier_available || first_party_auth_enabled +} + /// Validate the quarantine selector and its account-authentication prerequisite. fn quarantine_mode( backend: &str, @@ -220,7 +245,7 @@ fn quarantine_mode( if !account_auth_enabled { return Err(ServerError::Startup( - "publication quarantine requires valid OIDC configuration".to_string(), + "publication quarantine requires enabled account authentication".to_string(), )); } @@ -435,6 +460,24 @@ fn parse_memory_http_auth(raw: &str) -> Result); + +/// Build matching recovery cipher and provider components for the background worker. +fn build_recovery_delivery_components( + config: &ServerConfig, +) -> Result, ServerError> { + let cipher = RecoveryDeliveryCipher::from_config(config).map_err(ServerError::Startup)?; + let dispatcher = ResendRecoveryDispatcher::from_config(config).map_err(ServerError::Startup)?; + match (cipher, dispatcher) { + (None, None) => Ok(None), + (Some(cipher), Some(dispatcher)) => Ok(Some((cipher, Arc::new(dispatcher)))), + _ => Err(ServerError::Startup( + "password recovery components resolved inconsistently".to_string(), + )), + } +} + #[tokio::main] /// Resolve configuration, initialize backends, and run the HTTP server. async fn main() { @@ -461,7 +504,11 @@ async fn main() { } }; - let quarantine = match build_quarantine_store(&config, state.account_auth.is_some()).await { + let account_auth_enabled = account_auth_available( + state.account_auth.is_some(), + config.first_party_auth.enabled(), + ); + let quarantine = match build_quarantine_store(&config, account_auth_enabled).await { Ok(store) => store, Err(e) => { tracing::error!("startup failed: {e}"); @@ -469,6 +516,30 @@ async fn main() { } }; + let recovery_delivery = match build_recovery_delivery_components(&config) { + Ok(components) => components, + Err(error) => { + tracing::error!("startup failed: {error}"); + std::process::exit(3); + } + }; + + let (recovery_stop, recovery_task) = match recovery_delivery { + Some((cipher, dispatcher)) => { + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(false); + let catalog: Arc = postgres_catalog.clone(); + let task = tokio::spawn(run_recovery_delivery_worker( + catalog, + dispatcher, + cipher, + RecoveryDeliveryWorkerConfig::default(), + stop_receiver, + )); + (Some(stop_sender), Some(task)) + } + None => (None, None), + }; + let nonce_cleanup = tokio::spawn(run_signed_request_nonce_cleanup(postgres_catalog)); let server_result = match quarantine { @@ -483,6 +554,23 @@ async fn main() { } } + if let Some(stop_sender) = recovery_stop { + let _ = stop_sender.send(true); + } + if let Some(mut task) = recovery_task { + match tokio::time::timeout(Duration::from_secs(15), &mut task).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(%error, "recovery delivery worker stopped unexpectedly"); + } + Err(_) => { + task.abort(); + let _ = task.await; + tracing::warn!("recovery delivery worker exceeded its stop deadline"); + } + } + } + if let Err(e) = server_result { tracing::error!("server error: {e}"); let code = match e { @@ -500,7 +588,7 @@ mod tests { use super::*; #[test] - /// The default selector keeps publication admission disabled without OIDC. + /// The default selector keeps publication admission disabled without account authentication. fn quarantine_mode_accepts_disabled_without_account_auth() { assert_eq!( quarantine_mode("disabled", false).expect("disabled mode"), @@ -513,14 +601,23 @@ mod tests { fn quarantine_mode_requires_account_auth() { assert!(matches!( quarantine_mode("fs", false), - Err(ServerError::Startup(message)) if message.contains("OIDC") + Err(ServerError::Startup(message)) if message.contains("account authentication") )); assert!(matches!( quarantine_mode("r2", false), - Err(ServerError::Startup(message)) if message.contains("OIDC") + Err(ServerError::Startup(message)) if message.contains("account authentication") )); } + #[test] + /// Either supported authentication mechanism satisfies the quarantine prerequisite. + fn account_auth_availability_accepts_oidc_or_first_party() { + assert!(!account_auth_available(false, false)); + assert!(account_auth_available(true, false)); + assert!(account_auth_available(false, true)); + assert!(account_auth_available(true, true)); + } + #[test] /// Both supported quarantine backends resolve when account authentication exists. fn quarantine_mode_accepts_supported_backends() { diff --git a/crates/frameshift-server/src/middleware/account.rs b/crates/frameshift-server/src/middleware/account.rs index 2392342..fcf434a 100644 --- a/crates/frameshift-server/src/middleware/account.rs +++ b/crates/frameshift-server/src/middleware/account.rs @@ -5,7 +5,7 @@ use axum::http::header::{AUTHORIZATION, COOKIE, ORIGIN}; use axum::http::Method; use axum::middleware::Next; use axum::response::Response; -use chrono::Utc; +use chrono::{DateTime, Utc}; use frameshift_catalog::{AccountRecord, AccountSessionClientKind, AccountStatus, CatalogError}; use uuid::Uuid; @@ -23,10 +23,65 @@ pub struct AuthenticatedAccount { pub auth_time: Option, /// Local session identifier when authentication used a first-party token. pub local_session_id: Option, + /// Most recent successful local second-factor verification. + pub mfa_verified_at: Option>, /// Whether the local session token arrived through the secure browser cookie. pub via_cookie: bool, } +/// Require recent first-party MFA assurance on a privileged authenticated route. +pub async fn require_fresh_mfa( + State(state): State, + request: Request, + next: Next, +) -> Result { + if matches!(*request.method(), Method::GET | Method::HEAD) + && matches!(request.uri().path(), "/account" | "/v1/account") + { + return Ok(next.run(request).await); + } + let auth = request + .extensions() + .get::() + .ok_or_else(|| AppError::Unauthorized("account authentication required".to_string()))?; + validate_fresh_authentication(&state, auth)?; + Ok(next.run(request).await) +} + +/// Validate recent local MFA or OIDC authentication without consuming a request. +pub(crate) fn validate_fresh_authentication( + state: &AppState, + auth: &AuthenticatedAccount, +) -> Result<(), AppError> { + let now = Utc::now(); + if auth.local_session_id.is_some() { + let verified_at = auth + .mfa_verified_at + .ok_or_else(|| AppError::Forbidden("fresh MFA verification required".to_string()))?; + let freshness = chrono::Duration::from_std(state.config.first_party_auth.mfa_freshness_ttl) + .map_err(|_| AppError::Internal("MFA freshness duration is invalid".to_string()))?; + if verified_at > now + chrono::Duration::seconds(30) || verified_at < now - freshness { + return Err(AppError::Forbidden( + "fresh MFA verification required".to_string(), + )); + } + } else { + let auth_time = auth + .auth_time + .ok_or_else(|| AppError::Forbidden("fresh authentication required".to_string()))?; + let now_seconds = u64::try_from(now.timestamp()).unwrap_or_default(); + if auth_time > now_seconds.saturating_add(30) + || now_seconds.saturating_sub(auth_time) + > state.config.oidc.fresh_auth_max_age.as_secs() + { + return Err(AppError::Forbidden( + "fresh authentication required".to_string(), + )); + } + } + Ok(()) +} + /// Require an OIDC bearer token, provision its account, and reject disabled accounts. pub async fn require_account( State(state): State, @@ -107,6 +162,7 @@ async fn authenticate_account( account, auth_time: identity.auth_time, local_session_id: None, + mfa_verified_at: None, via_cookie: false, }) } @@ -142,6 +198,10 @@ async fn authenticate_local_token( Err(CatalogError::NotFound { .. }) => return Ok(None), Err(error) => return Err(AppError::from_catalog(error, "account session")), }; + let expects_cookie = session.client_kind == AccountSessionClientKind::Browser; + if via_cookie != expects_cookie { + return Ok(None); + } let account = state .catalog .get_account(session.account_id) @@ -170,6 +230,7 @@ async fn authenticate_local_token( account, auth_time: u64::try_from(session.created_at.timestamp()).ok(), local_session_id: Some(session.id), + mfa_verified_at: session.mfa_verified_at, via_cookie, })) } diff --git a/crates/frameshift-server/src/password_blocklist.rs b/crates/frameshift-server/src/password_blocklist.rs new file mode 100644 index 0000000..e07663e --- /dev/null +++ b/crates/frameshift-server/src/password_blocklist.rs @@ -0,0 +1,58 @@ +//! Deterministic compromised and deployment-specific password rejection. + +use sha2::{Digest as _, Sha256}; + +/// Embedded lowercase SHA-256 digests, one per non-comment line. +const BLOCKLIST_DIGESTS: &str = include_str!("password_blocklist.txt"); + +/// Reviewable service-specific values that attackers are expected to try. +const EXPECTED_PASSWORDS: &[&str] = &[ + "frameshiftpassword", + "frameshift-password", + "frameshift123456", + "ghostframepassword", + "syntheospassword", +]; + +/// Return whether a candidate matches a known or expected password. +/// +/// Outer whitespace and ASCII case are ignored only for comparison. Callers +/// keep the original password bytes unchanged for hashing when the candidate +/// is accepted. +pub(crate) fn is_blocklisted(password: &str) -> bool { + let normalized = password.trim().to_ascii_lowercase(); + if EXPECTED_PASSWORDS.contains(&normalized.as_str()) { + return true; + } + let candidate_digest = hex::encode(Sha256::digest(normalized.as_bytes())); + + BLOCKLIST_DIGESTS + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .any(|digest| digest == candidate_digest) +} + +#[cfg(test)] +/// Unit tests for deterministic blocklist matching. +mod tests { + use super::is_blocklisted; + + /// The pinned compromised-password baseline is active. + #[test] + fn common_password_is_blocklisted() { + assert!(is_blocklisted("password")); + } + + /// Expected FrameShift variants ignore outer whitespace and ASCII case. + #[test] + fn service_specific_password_is_normalized_for_comparison() { + assert!(is_blocklisted(" FrameShiftPassword ")); + } + + /// An unrelated passphrase does not produce a false positive. + #[test] + fn unrelated_passphrase_is_not_blocklisted() { + assert!(!is_blocklisted("correct horse battery staple")); + } +} diff --git a/crates/frameshift-server/src/password_blocklist.txt b/crates/frameshift-server/src/password_blocklist.txt new file mode 100644 index 0000000..706101a --- /dev/null +++ b/crates/frameshift-server/src/password_blocklist.txt @@ -0,0 +1,101 @@ +# SHA-256 digests of normalized compromised and FrameShift-specific passwords. +# Regeneration and provenance are documented in Accounts-and-Publisher-Identity.md. +000c285457fc971f862a79b786476c78812c8897063c6fa9c045f579a3b2d63f +01621148306fc8fb7c2b95eeb5c37e375f90db53cf8313ea87c9c34c05b7e0e5 +03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4 +04e77bf8f95cb3e1a36a59d1e93857c411930db646b46c218a0352e432023cf2 +0522a55e2d5f0993a3d66d28864b2862a7218a75ea7968b075333434404485c3 +059a00192592d5444bc0caad7203f98b506332e2cf7abb35d684ea9bf7c18f08 +08ddff4ebe39249a9208cd305b7d14091b1ebabef6adfa897cc34675fa0e0848 +0bb09d80600eec3eb9d7793a6f859bedde2a2d83899b70bd78e961ed674b32f4 +0f28c4960d96647e77e7ab6d13b85bd16c7ca56f45df802cdc763a5e5c0c7863 +0ffe1abd1a08215353c233d6e009613e95eec4253832a761af28ff37ac5a150c +136c67657614311f32238751044a0a3c0294f2a521e573afa8e496992d3786ba +13b1f7ec5beaefc781e43a3b344371cd49923a8a05edd71844b92f56f6a08d38 +1532e76dbe9d43d0dea98c331ca5ae8a65c5e8e8b99d3e2a42ae989356f6242a +15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225 +1c8bfe8f801d79745c4631d09fff36c82aa37fc4cce4fc946683d7b336b63032 +1df1854015e31ca286d015345eaff29a6c6073f70984a3a746823d4cac16b075 +1ecd41c03ef78bd6daeaa6bb008896607a8413bf8ba6266be80327554b370a9e +203b70b5ae883932161bbd0bded9357e763e63afce98b16230be33f0b94c2cc5 +308738b8195da46d65c96f4ee3909032e27c818d8a079bccb5a1ef62e8daaa45 +34550715062af006ac4fab288de67ecb44793c3a05c475227241535f6ef7a81b +37bfdcb4c50793a6286fa0efe07b9e6bba8605b2c32e329fb9f71f225545f027 +3d14c2d4e4ced81e459e4ace7c01466a700000fb94a3bbe944a55fb92693e879 +3ea87a56da3844b420ec2925ae922bc731ec16a4fc44dcbeafdad49b0e61d39c +3fe1f7584833183e2da842b2f18123186919d4aa9828dbebdb3956429d9607bb +4007d46292298e83da10d0763d95d5139fe0c157148d0587aa912170414ccba6 +481f6cc0511143ccdd7e2d1b1b94faf0a700a8b49cd13922a70b5ae28acaa8c5 +49eff747f7b66f70133bfe00aa8ac2d6b0fbee5be80e52537b0163f147d20418 +52e8e47b38e854580afce4aade15dbd5ce0c0464da711afe71da123687d5a4cd +5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5 +5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 +6161b0a284159565a0f7d5df2dd2698b5f87906cd91ff5322caf179b451f5a41 +6161b2838ffa6ce17b84db3b45b4f8437855ecf43e75de2d1ad0008eaae91aa0 +6382deaf1f5dc6e792b76db4a4a7bf2ba468884e000b25e7928e621e27fb23cb +65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5 +686f746a95b6f836d7d70567c302c3f9ebb5ee0def3d1220ee9d4e9f34f5e131 +68be7550846ecd878947b4eb0ac13d3cca3cf6c4940c94d90163e0a15e947203 +6ac3c336e4094835293a3fed8a4b5fedde1b5e2626d9838fed50693bba00af0e +6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090 +73cd1b16c4fb83061ad18a0b29b9643a68d4640075a466dc9e51682f84a847f5 +74fca0325b5fdb3a34badb40a2581cfbd5344187e8d3432952a5abc0929c1246 +81a83544cf93c245178cbc1620030f1123f435af867c79d87135983c52ab39d9 +8360632a2b41498c6f979a15aced6655a2857f259533e77106228c683c4ab5af +8588310a98676af6e22563c1559e1ae20f85950792bdcd0c8f334867c54581cd +873ac9ffea4dd04fa719e8920cd6938f0c23cd678af330939cff53c3d2855f34 +88b1cca59060320e5e5662a7da636884eb7580f4dc7e22cfb6f88b8f99045a71 +8a9bcf1e51e812d0af8465a8dbcc9f741064bf0af3b3d08e6b0246437c19f7fb +8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414 +8c1cdb9cb4dbac6dbb6ebd118ec8f9523d22e4e4cb8cc9df5f7e1e499bba3c10 +8cbbcf29d9cef89675c5f5c1dcfe827d0570416a5aaba30dd0de159661ad905b +8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92 +8e924025a26c584ad4ac6365116e09b852ae6b7016da4c0851e269348d93c228 +8f27f432fcbaa4b5180a1cc7a8fa166a93cda3c1bce6f19922dd519d02f4bb39 +91b4d142823f7d20c5f08df69122de43f35f057a988d9619f6d3138485c9a203 +94edf28c6d6da38fd35d7ad53e485307f89fbeaf120485c8d17a43f323deee71 +96cae35ce8a9b0244178bf28e4966c2ce1b8385723a96a6b838858cdd6ca0a1e +9a900403ac313ba27a1bc81f0932652b8020dac92c234d98fa0b06bf0040ecfd +9ce8db922a8f4a7abd859adee70bd8b7a63321265487da54cf4bed6a69eb3e1b +a01edad91c00abe7be5b72b5e36bf4ce3c6f26e8bce3340eba365642813ab8b6 +a0561fd649cdb6baa784055f051bad796ea0afef17fca38219549deeba4e8c1a +a30c89b446e0e8ab6b8c00c986f586c1ce378aaf0c5348c660e3ad1779be9886 +a320480f534776bddb5cdb54b1e93d210a3c7d199e80a23c1b2178497b184c76 +a92f6bdb75789bccc118adfcf704029aa58063c604bab4fcdd9cd126ef9b69af +a941a4c4fd0c01cddef61b8be963bf4c1e2b0811c037ce3f1835fddf6ef6c223 +a9c43be948c5cabd56ef2bacffb77cdaa5eec49dd5eb0cc4129cf3eda5f0e74c +aa97302150fce811425cd84537028a5afbe37e3f1362ad45a51d467e17afdc9c +aae5be5f6474904b686f639e0fcfd2be440121cd889fa381a94b71750758345e +abc529a4b673cbbbc532e584706cb8137be876ad53269df3b97fbd40fc76fe57 +af41e68e1309fa29a5044cbdc36b90a3821d8807e68c7675a6c495112bc8a55f +b54a1af8b666f61c2dd5ae8f8a543133409fd28c3b78064c5db993bf2c8e77bc +b89dab808c585f889185b815fb5a704b2fcbcff4b2a32e03d584a6988d68784f +b9dd960c1753459a78115d3cb845a57d924b6877e805b08bd01086ccdf34433c +bcb15f821479b4d5772bd0ca866c00ad5f926e3580720659cc80d39c9d09802a +bd3dae5fb91f88a4f0978222dfd58f59a124257cb081486387cbae9df11fb879 +c2eb7898bb6771503ffee5d0c722e5b561fe480edbc30141880a1cdf1e5b1cf6 +c64975ba3cf3f9cd58459710b0a42369f34b0759c9967fb5a47eea488e8bea79 +c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646 +c7c1319276e936c8d64f1d5ed80cd8a0cf54e6dea7b0125533eb4163e03a2c11 +cbeaff314ef5ad032caa60ee2e8d8144ae52a8572c7d6f75631f3bd4080a7b16 +d38681074467c0bc147b17a9a12b9efa8cc10bcf545f5b0bccccf5a93c4a2b79 +d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1 +d979885447a413abb6d606a5d0f45c3b7809e6fde2c83f0df3426f1fc9bfed97 +da5fe20988c8e92bdbb374788b85e9da76a6677fa1cde68c62842c0ab083fbaf +dbc4a04327176e6577b4da46df04564150053960eba5d89587dad1f76a818d80 +e0bc60c82713f64ef8a57c0c40d02ce24fd0141d5cc3086259c19b1e62a62bea +e1fc45f7880e0505ff0b6a079b9af149f225e260f59b1d20225357a8cce8ffd8 +e4ad93ca07acb8d908a3aa41e920ea4f4ef4f26e7f86cf8291c5db289780a5ae +e83664255c6963e962bb20f9fcfaad1b570ddf5da69f5444ed37e5260f3ef689 +e8f56862d74ef5599af4eeca73924bfa44a6773a497af0c29c48e18729ba6ff0 +e9a63a4eb15738ae85cd416221c8fcc4ccc0018fac91335b42eaa016c76e87f9 +ec4c88ca7f69534f10c0611c1ecd13e7c2cdf73e1b915e9fd0cf27ac10da43fa +ed02457b5c41d964dbd2f2a609d63fe1bb7528dbe55e1abf5b52c249cd735797 +ed45d626b07112a8a501d9672f3b92796a6754b8d8d9cb4c617fec9774889220 +ee79976c9380d5e337fc1c095ece8c8f22f91f306ceeb161fa51fecede2c4ba1 +ef797c8118f02dfb649607dd5d3f8c7623048c9c063d532cc95c5ed7a898a64f +f50c51ed2315dcf3fa88181cf033f8029cac64f7dea4048327ca032ec102ea74 +fa2115f8d576a6ab722956697fc759c31d1cd6b93c8336bfebf73ed5cba2ff49 +fbfb386efea67e816f2dda0a8c94a98eb203757aebb3f55f183755a192d44467 +fc52fabe94c0e037d2df4498e87481a6438960c9f73d517584a7a5c564535ac4 +fc613b4dfd6736a7bd268c8a0e74ed0d1c04a959f59dd74ef2874983fd443fc9 diff --git a/crates/frameshift-server/src/recovery_delivery.rs b/crates/frameshift-server/src/recovery_delivery.rs new file mode 100644 index 0000000..8ce10f6 --- /dev/null +++ b/crates/frameshift-server/src/recovery_delivery.rs @@ -0,0 +1,1065 @@ +//! Encrypted password-recovery delivery and the Resend provider boundary. +//! +//! Reset bearers are encrypted before the catalog transaction persists an +//! outbox row. Only the delivery worker decrypts a claimed payload, and the +//! provider receives a stable outbox UUID as its idempotency key. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload}; +use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce}; +use chrono::{DateTime, Utc}; +use frameshift_catalog::{ + CatalogBackend, PasswordRecoveryDeliveryClaimRequest, PasswordRecoveryDeliveryKind, + PasswordRecoveryDeliveryRecord, +}; +use reqwest::header::{HeaderMap, HeaderValue, RETRY_AFTER, USER_AGENT}; +use secrecy::{ExposeSecret as _, SecretString}; +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; +use uuid::Uuid; +use zeroize::{Zeroize as _, Zeroizing}; + +use crate::config::ServerConfig; + +/// Resend endpoint used by the production recovery dispatcher. +const RESEND_EMAIL_ENDPOINT: &str = "https://api.resend.com/emails"; +/// Bounded provider request timeout, including response-body decoding. +const RESEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +/// Maximum provider-directed retry delay accepted from an HTTP header. +const MAX_PROVIDER_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); +/// Domain-separation prefix for recovery delivery associated data. +const RECOVERY_AAD_DOMAIN: &[u8] = b"frameshift-password-recovery-delivery-v1\0"; +/// Version tag for an encoded reset-link payload. +const RESET_PAYLOAD_TAG: &[u8; 2] = b"R1"; +/// Version tag for an encoded password-change notification. +const PASSWORD_CHANGED_PAYLOAD_TAG: &[u8; 2] = b"C1"; +/// Poll interval used by the durable recovery-delivery worker. +const DEFAULT_WORKER_POLL_INTERVAL: Duration = Duration::from_secs(2); +/// Lease duration after which another worker may reclaim an abandoned row. +const DEFAULT_WORKER_CLAIM_TTL: Duration = Duration::from_secs(60); +/// Initial delay after one retryable provider failure. +const DEFAULT_WORKER_RETRY_INITIAL: Duration = Duration::from_secs(15); +/// Maximum locally calculated retry delay. +const DEFAULT_WORKER_RETRY_MAX: Duration = Duration::from_secs(60 * 60); +/// Maximum number of provider attempts for one delivery. +const DEFAULT_WORKER_MAX_ATTEMPTS: u32 = 8; +/// Maximum number of rows leased per polling cycle. +const DEFAULT_WORKER_BATCH_SIZE: u32 = 1; + +/// Opaque AEAD output persisted in one delivery outbox row. +pub struct EncryptedRecoveryPayload { + /// XChaCha20-Poly1305 ciphertext including its authentication tag. + pub ciphertext: Vec, + /// Random 192-bit XChaCha nonce. + pub nonce: [u8; 24], + /// Positive key version bound into associated data. + pub key_version: i16, +} + +/// In-memory recovery payload borrowed from one zeroizing plaintext buffer. +pub enum RecoveryDeliveryPayload<'a> { + /// Single-use reset token and the HTTPS page that consumes its fragment. + Reset { + /// HTTPS marketplace recovery page without query or fragment. + reset_url: &'a str, + /// Canonical random reset bearer. + token: &'a str, + }, + /// Notification sent after a password was changed and sessions revoked. + PasswordChanged, +} + +/// Authenticated recovery-delivery cipher using one active deployment key. +pub struct RecoveryDeliveryCipher { + /// Active 256-bit XChaCha20-Poly1305 key. + key: [u8; 32], + /// Positive key version stored beside every ciphertext. + key_version: i16, +} + +/// Secure construction and authenticated payload operations. +impl RecoveryDeliveryCipher { + /// Build the active cipher when recovery is enabled and valid. + pub fn from_config(config: &ServerConfig) -> Result, String> { + let Some(key) = config.password_recovery_key()? else { + return Ok(None); + }; + Ok(Some(Self { + key, + key_version: config.first_party_auth.recovery.key_version, + })) + } + + /// Encrypt one reset bearer and its configured recovery page. + pub fn encrypt_reset( + &self, + outbox_id: Uuid, + reset_url: &str, + token: &str, + ) -> Result { + let reset_url_len = + u16::try_from(reset_url.len()).map_err(|_| RecoveryCryptoError::InvalidPlaintext)?; + let mut plaintext = Zeroizing::new(Vec::with_capacity( + RESET_PAYLOAD_TAG.len() + 2 + reset_url.len() + token.len(), + )); + plaintext.extend_from_slice(RESET_PAYLOAD_TAG); + plaintext.extend_from_slice(&reset_url_len.to_be_bytes()); + plaintext.extend_from_slice(reset_url.as_bytes()); + plaintext.extend_from_slice(token.as_bytes()); + self.encrypt( + outbox_id, + PasswordRecoveryDeliveryKind::Reset, + plaintext.as_slice(), + ) + } + + /// Encrypt the fixed payload for a password-change notification. + pub fn encrypt_password_changed( + &self, + outbox_id: Uuid, + ) -> Result { + self.encrypt( + outbox_id, + PasswordRecoveryDeliveryKind::PasswordChanged, + PASSWORD_CHANGED_PAYLOAD_TAG, + ) + } + + /// Decrypt and authenticate one claimed outbox payload into zeroizing memory. + pub fn decrypt( + &self, + outbox_id: Uuid, + kind: PasswordRecoveryDeliveryKind, + key_version: i16, + nonce: &[u8], + ciphertext: &[u8], + ) -> Result>, RecoveryCryptoError> { + if key_version != self.key_version { + return Err(RecoveryCryptoError::UnknownKeyVersion); + } + let nonce: &[u8; 24] = nonce + .try_into() + .map_err(|_| RecoveryCryptoError::InvalidCiphertext)?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + let aad = recovery_aad(outbox_id, kind, key_version); + cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: &aad, + }, + ) + .map(Zeroizing::new) + .map_err(|_| RecoveryCryptoError::AuthenticationFailed) + } + + /// Encrypt one encoded plaintext under random nonce and bound metadata. + fn encrypt( + &self, + outbox_id: Uuid, + kind: PasswordRecoveryDeliveryKind, + plaintext: &[u8], + ) -> Result { + let cipher = XChaCha20Poly1305::new(Key::from_slice(&self.key)); + let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng); + let aad = recovery_aad(outbox_id, kind, self.key_version); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|_| RecoveryCryptoError::EncryptionFailed)?; + Ok(EncryptedRecoveryPayload { + ciphertext, + nonce: nonce.into(), + key_version: self.key_version, + }) + } +} + +/// Erase the active delivery key when its cipher leaves memory. +impl Drop for RecoveryDeliveryCipher { + /// Zeroize the key bytes in place. + fn drop(&mut self) { + self.key.zeroize(); + } +} + +/// Parse an authenticated plaintext without copying its bearer or URL. +pub fn parse_recovery_delivery_payload( + plaintext: &[u8], +) -> Result, RecoveryCryptoError> { + if plaintext == PASSWORD_CHANGED_PAYLOAD_TAG { + return Ok(RecoveryDeliveryPayload::PasswordChanged); + } + if !plaintext.starts_with(RESET_PAYLOAD_TAG) || plaintext.len() < 4 { + return Err(RecoveryCryptoError::InvalidPlaintext); + } + let reset_url_len = usize::from(u16::from_be_bytes([plaintext[2], plaintext[3]])); + let reset_url_end = 4_usize + .checked_add(reset_url_len) + .filter(|end| *end < plaintext.len()) + .ok_or(RecoveryCryptoError::InvalidPlaintext)?; + let reset_url = std::str::from_utf8(&plaintext[4..reset_url_end]) + .map_err(|_| RecoveryCryptoError::InvalidPlaintext)?; + let token = std::str::from_utf8(&plaintext[reset_url_end..]) + .map_err(|_| RecoveryCryptoError::InvalidPlaintext)?; + if token.is_empty() { + return Err(RecoveryCryptoError::InvalidPlaintext); + } + Ok(RecoveryDeliveryPayload::Reset { reset_url, token }) +} + +/// Sanitized cryptographic failure classifications. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RecoveryCryptoError { + /// Enabled configuration references a key version unavailable to this process. + #[error("unknown recovery delivery key version")] + UnknownKeyVersion, + /// Stored nonce or ciphertext shape is invalid. + #[error("invalid recovery delivery ciphertext")] + InvalidCiphertext, + /// Associated data, nonce, ciphertext, or key failed authentication. + #[error("recovery delivery authentication failed")] + AuthenticationFailed, + /// The authenticated plaintext does not match a supported encoding. + #[error("invalid recovery delivery plaintext")] + InvalidPlaintext, + /// The AEAD primitive rejected encryption. + #[error("recovery delivery encryption failed")] + EncryptionFailed, +} + +/// Successful provider acknowledgement stored with a completed outbox row. +#[derive(Debug)] +pub struct RecoveryDispatchReceipt { + /// Provider-assigned message identifier. + pub provider_message_id: String, +} + +/// Sanitized delivery failure used to choose retry or terminal handling. +#[derive(Debug, thiserror::Error)] +pub enum RecoveryDispatchError { + /// A transport or provider condition that may succeed without payload changes. + #[error("retryable recovery provider failure: {reason}")] + Retryable { + /// Stable non-secret failure category. + reason: &'static str, + /// Optional provider-directed delay capped by the adapter. + retry_after: Option, + }, + /// A request or credential condition that retries cannot repair. + #[error("permanent recovery provider failure: {reason}")] + Permanent { + /// Stable non-secret failure category. + reason: &'static str, + }, +} + +/// Abstract provider boundary injected into the recovery outbox worker. +#[async_trait] +pub trait RecoveryDeliveryDispatcher: Send + Sync { + /// Deliver one authenticated plaintext using a stable provider idempotency key. + async fn deliver( + &self, + outbox_id: Uuid, + recipient: &str, + payload: RecoveryDeliveryPayload<'_>, + ) -> Result; +} + +/// Bounded polling, lease, and retry policy for the delivery worker. +#[derive(Clone, Copy, Debug)] +pub struct RecoveryDeliveryWorkerConfig { + /// Delay between catalog claim attempts. + pub poll_interval: Duration, + /// Age after which an abandoned claim may be reclaimed. + pub claim_ttl: Duration, + /// Maximum rows acquired in one claim transaction. + pub batch_size: u32, + /// Delay used after the first retryable provider failure. + pub retry_initial: Duration, + /// Upper bound for locally calculated exponential backoff. + pub retry_max: Duration, + /// Maximum number of provider requests for one outbox row. + pub max_attempts: u32, +} + +/// Production defaults for the recovery delivery worker. +impl Default for RecoveryDeliveryWorkerConfig { + /// Return conservative bounded polling, leasing, and retry settings. + fn default() -> Self { + Self { + poll_interval: DEFAULT_WORKER_POLL_INTERVAL, + claim_ttl: DEFAULT_WORKER_CLAIM_TTL, + batch_size: DEFAULT_WORKER_BATCH_SIZE, + retry_initial: DEFAULT_WORKER_RETRY_INITIAL, + retry_max: DEFAULT_WORKER_RETRY_MAX, + max_attempts: DEFAULT_WORKER_MAX_ATTEMPTS, + } + } +} + +/// Validation for caller-supplied worker policy. +impl RecoveryDeliveryWorkerConfig { + /// Reject zero, over-leased, or internally inconsistent policy values. + fn validate(self) -> Result { + if self.poll_interval.is_zero() + || self.claim_ttl.is_zero() + || self.batch_size != 1 + || self.retry_initial.is_zero() + || self.retry_max < self.retry_initial + || self.max_attempts == 0 + { + return Err("invalid recovery delivery worker configuration"); + } + Ok(self) + } +} + +/// Production Resend email dispatcher with redacted credentials. +pub struct ResendRecoveryDispatcher { + /// Redirect-free bounded HTTP client. + client: reqwest::Client, + /// Provider endpoint, overridable only by direct construction in tests. + endpoint: String, + /// Dedicated sending credential. + api_key: SecretString, + /// Verified FrameShift sender identity. + from_address: String, +} + +/// Construction helpers for the production provider adapter. +impl ResendRecoveryDispatcher { + /// Build the production dispatcher when recovery configuration is complete. + pub fn from_config(config: &ServerConfig) -> Result, String> { + let configured = match config.password_recovery_key()? { + Some(mut delivery_key) => { + delivery_key.zeroize(); + true + } + None => false, + }; + if !configured { + return Ok(None); + } + Self::new( + RESEND_EMAIL_ENDPOINT.to_string(), + config.first_party_auth.recovery.provider_api_key.clone(), + config.first_party_auth.recovery.from_address.clone(), + ) + .map(Some) + } + + /// Build a redirect-free dispatcher for an explicit endpoint. + pub fn new( + endpoint: String, + api_key: SecretString, + from_address: String, + ) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(RESEND_REQUEST_TIMEOUT) + .build() + .map_err(|error| format!("recovery provider client initialization failed: {error}"))?; + Ok(Self { + client, + endpoint, + api_key, + from_address, + }) + } +} + +/// Redacted formatting for the concrete provider adapter. +impl std::fmt::Debug for ResendRecoveryDispatcher { + /// Format non-secret endpoint settings while hiding the provider key. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResendRecoveryDispatcher") + .field("endpoint", &self.endpoint) + .field("api_key", &"[REDACTED]") + .field("from_address", &self.from_address) + .finish_non_exhaustive() + } +} + +/// Borrowed request body accepted by the Resend send-email endpoint. +#[derive(Serialize)] +struct ResendEmailRequest<'a> { + /// Verified sender identity. + from: &'a str, + /// One normalized verified account email. + to: [&'a str; 1], + /// Stable message subject. + subject: &'a str, + /// Plain-text message body. + text: &'a str, + /// Minimal HTML message body. + html: &'a str, +} + +/// Successful Resend response body. +#[derive(Deserialize)] +struct ResendEmailResponse { + /// Provider-assigned message identifier. + id: String, +} + +/// Resend implementation of the recovery provider boundary. +#[async_trait] +impl RecoveryDeliveryDispatcher for ResendRecoveryDispatcher { + /// Send one reset or password-change message without logging its body. + async fn deliver( + &self, + outbox_id: Uuid, + recipient: &str, + payload: RecoveryDeliveryPayload<'_>, + ) -> Result { + let (subject, text, html) = render_recovery_email(payload); + let request = ResendEmailRequest { + from: &self.from_address, + to: [recipient], + subject, + text: text.as_str(), + html: html.as_str(), + }; + let response = self + .client + .post(&self.endpoint) + .bearer_auth(self.api_key.expose_secret()) + .header( + USER_AGENT, + concat!("frameshift/", env!("CARGO_PKG_VERSION")), + ) + .header("Idempotency-Key", outbox_id.to_string()) + .json(&request) + .send() + .await + .map_err(|_| RecoveryDispatchError::Retryable { + reason: "transport", + retry_after: None, + })?; + let status = response.status(); + let retry_after = retry_after(response.headers()); + if status == reqwest::StatusCode::OK { + let response = response.json::().await.map_err(|_| { + RecoveryDispatchError::Retryable { + reason: "invalid_success_response", + retry_after: None, + } + })?; + if response.id.is_empty() + || response.id.len() > 256 + || response.id.trim() != response.id + || response.id.chars().any(char::is_control) + { + return Err(RecoveryDispatchError::Retryable { + reason: "invalid_success_response", + retry_after: None, + }); + } + return Ok(RecoveryDispatchReceipt { + provider_message_id: response.id, + }); + } + if status == reqwest::StatusCode::REQUEST_TIMEOUT + || status == reqwest::StatusCode::CONFLICT + || status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() + { + return Err(RecoveryDispatchError::Retryable { + reason: "provider_status", + retry_after, + }); + } + Err(RecoveryDispatchError::Permanent { + reason: "provider_rejected_request", + }) + } +} + +/// Claim, decrypt, deliver, and durably settle recovery outbox rows until stopped. +pub async fn run_recovery_delivery_worker( + catalog: Arc, + dispatcher: Arc, + cipher: RecoveryDeliveryCipher, + config: RecoveryDeliveryWorkerConfig, + mut stop: watch::Receiver, +) { + let config = match config.validate() { + Ok(config) => config, + Err(error) => { + tracing::error!(%error, "recovery delivery worker configuration rejected"); + return; + } + }; + let claim_ttl = match chrono::Duration::from_std(config.claim_ttl) { + Ok(duration) => duration, + Err(_) => { + tracing::error!("recovery delivery worker claim duration is unsupported"); + return; + } + }; + let mut interval = tokio::time::interval(config.poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + if *stop.borrow() { + return; + } + tokio::select! { + changed = stop.changed() => { + if changed.is_err() || *stop.borrow() { + return; + } + } + _ = interval.tick() => { + let now = Utc::now(); + let claim_id = Uuid::new_v4(); + let stale_before = now.checked_sub_signed(claim_ttl).unwrap_or(DateTime::::MIN_UTC); + let deliveries = match catalog + .claim_password_recovery_deliveries(PasswordRecoveryDeliveryClaimRequest { + claim_id, + claimed_at: now, + stale_before, + limit: config.batch_size, + }) + .await + { + Ok(deliveries) => deliveries, + Err(_) => { + tracing::warn!("recovery delivery claim failed"); + continue; + } + }; + + for delivery in deliveries { + process_recovery_delivery( + catalog.as_ref(), + dispatcher.as_ref(), + &cipher, + config, + claim_id, + delivery, + ) + .await; + } + } + } + } +} + +/// Process one claimed row while preserving its catalog claim fence. +async fn process_recovery_delivery( + catalog: &dyn CatalogBackend, + dispatcher: &dyn RecoveryDeliveryDispatcher, + cipher: &RecoveryDeliveryCipher, + config: RecoveryDeliveryWorkerConfig, + claim_id: Uuid, + delivery: PasswordRecoveryDeliveryRecord, +) { + if delivery.claim_id != Some(claim_id) { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery returned with an invalid claim fence"); + return; + } + + let attempt_started_at = Utc::now(); + if attempt_started_at >= delivery.expires_at { + fail_recovery_delivery(catalog, &delivery, claim_id, attempt_started_at, "expired").await; + return; + } + if delivery.attempt_count > config.max_attempts { + fail_recovery_delivery( + catalog, + &delivery, + claim_id, + attempt_started_at, + "attempt_limit", + ) + .await; + return; + } + + let plaintext = match cipher.decrypt( + delivery.id, + delivery.kind, + delivery.key_version, + &delivery.nonce, + &delivery.ciphertext, + ) { + Ok(plaintext) => plaintext, + Err(error) => { + fail_recovery_delivery( + catalog, + &delivery, + claim_id, + attempt_started_at, + recovery_crypto_error_code(&error), + ) + .await; + return; + } + }; + let payload = match parse_recovery_delivery_payload(&plaintext) { + Ok(payload) => payload, + Err(error) => { + fail_recovery_delivery( + catalog, + &delivery, + claim_id, + attempt_started_at, + recovery_crypto_error_code(&error), + ) + .await; + return; + } + }; + + match dispatcher + .deliver(delivery.id, &delivery.recipient, payload) + .await + { + Ok(receipt) => { + let sent_at = Utc::now(); + match catalog + .mark_password_recovery_delivery_sent( + delivery.id, + claim_id, + sent_at, + receipt.provider_message_id, + ) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery acknowledgement lost its claim fence") + } + Err(_) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery acknowledgement failed") + } + } + } + Err(RecoveryDispatchError::Permanent { reason }) => { + fail_recovery_delivery(catalog, &delivery, claim_id, Utc::now(), reason).await; + } + Err(RecoveryDispatchError::Retryable { + reason, + retry_after, + }) => { + settle_retryable_recovery_delivery( + catalog, + &delivery, + claim_id, + Utc::now(), + config, + retry_after, + reason, + ) + .await; + } + } +} + +/// Mark one claimed delivery permanently failed with a bounded static code. +async fn fail_recovery_delivery( + catalog: &dyn CatalogBackend, + delivery: &PasswordRecoveryDeliveryRecord, + claim_id: Uuid, + failed_at: DateTime, + error_code: &'static str, +) { + match catalog + .fail_password_recovery_delivery(delivery.id, claim_id, failed_at, error_code.to_string()) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery failure lost its claim fence") + } + Err(_) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery failure could not be persisted") + } + } +} + +/// Retry one transient failure unless policy or expiry requires terminal failure. +async fn settle_retryable_recovery_delivery( + catalog: &dyn CatalogBackend, + delivery: &PasswordRecoveryDeliveryRecord, + claim_id: Uuid, + failed_at: DateTime, + config: RecoveryDeliveryWorkerConfig, + provider_delay: Option, + error_code: &'static str, +) { + if delivery.attempt_count >= config.max_attempts { + fail_recovery_delivery(catalog, delivery, claim_id, failed_at, "attempt_limit").await; + return; + } + let delay = recovery_retry_delay(config, delivery.attempt_count, provider_delay); + let Ok(delay) = chrono::Duration::from_std(delay) else { + fail_recovery_delivery( + catalog, + delivery, + claim_id, + failed_at, + "invalid_retry_delay", + ) + .await; + return; + }; + let Some(next_attempt_at) = failed_at.checked_add_signed(delay) else { + fail_recovery_delivery( + catalog, + delivery, + claim_id, + failed_at, + "invalid_retry_delay", + ) + .await; + return; + }; + if next_attempt_at >= delivery.expires_at { + fail_recovery_delivery(catalog, delivery, claim_id, failed_at, "retry_after_expiry").await; + return; + } + + match catalog + .retry_password_recovery_delivery( + delivery.id, + claim_id, + next_attempt_at, + error_code.to_string(), + ) + .await + { + Ok(true) => {} + Ok(false) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery retry lost its claim fence") + } + Err(_) => { + tracing::warn!(delivery_id = %delivery.id, "recovery delivery retry could not be persisted") + } + } +} + +/// Calculate capped exponential backoff while honoring a provider minimum. +fn recovery_retry_delay( + config: RecoveryDeliveryWorkerConfig, + attempt_count: u32, + provider_delay: Option, +) -> Duration { + let exponent = attempt_count.saturating_sub(1).min(16); + let multiplier = 1_u32 << exponent; + let local_delay = config + .retry_initial + .saturating_mul(multiplier) + .min(config.retry_max); + provider_delay + .map(|delay| local_delay.max(delay.min(MAX_PROVIDER_RETRY_AFTER))) + .unwrap_or(local_delay) +} + +/// Map cryptographic details to non-secret bounded persistence codes. +fn recovery_crypto_error_code(error: &RecoveryCryptoError) -> &'static str { + match error { + RecoveryCryptoError::UnknownKeyVersion => "unknown_key_version", + RecoveryCryptoError::InvalidCiphertext => "invalid_ciphertext", + RecoveryCryptoError::AuthenticationFailed => "authentication_failed", + RecoveryCryptoError::InvalidPlaintext => "invalid_plaintext", + RecoveryCryptoError::EncryptionFailed => "encryption_failed", + } +} + +/// Build stable text and HTML bodies from one authenticated payload. +fn render_recovery_email( + payload: RecoveryDeliveryPayload<'_>, +) -> (&'static str, Zeroizing, Zeroizing) { + match payload { + RecoveryDeliveryPayload::Reset { reset_url, token } => { + let link = Zeroizing::new(format!("{reset_url}#token={token}")); + let escaped_link = escape_html(link.as_str()); + ( + "Reset your FrameShift password", + Zeroizing::new(format!( + "A password reset was requested for your FrameShift account. Open this link to choose a new password:\n\n{}\n\nIf you did not request this, you can ignore this email.", + link.as_str() + )), + Zeroizing::new(format!( + "

A password reset was requested for your FrameShift account.

Choose a new password

If you did not request this, you can ignore this email.

", + escaped_link.as_str() + )), + ) + } + RecoveryDeliveryPayload::PasswordChanged => ( + "Your FrameShift password was changed", + Zeroizing::new( + "Your FrameShift password was changed and all existing sessions were signed out. If you did not make this change, contact FrameShift support immediately." + .to_string(), + ), + Zeroizing::new( + "

Your FrameShift password was changed and all existing sessions were signed out.

If you did not make this change, contact FrameShift support immediately.

" + .to_string(), + ), + ), + } +} + +/// Escape the five HTML-sensitive characters in one configured reset link. +fn escape_html(value: &str) -> Zeroizing { + let mut escaped = Zeroizing::new(String::with_capacity(value.len())); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +/// Parse and cap an integer `Retry-After` delay without reading response bodies. +fn retry_after(headers: &HeaderMap) -> Option { + let seconds = headers + .get(RETRY_AFTER)? + .to_str() + .ok()? + .parse::() + .ok()?; + Some(Duration::from_secs(seconds).min(MAX_PROVIDER_RETRY_AFTER)) +} + +/// Construct unambiguous associated data for one immutable outbox identity. +fn recovery_aad(outbox_id: Uuid, kind: PasswordRecoveryDeliveryKind, key_version: i16) -> Vec { + let kind_tag = match kind { + PasswordRecoveryDeliveryKind::Reset => 1_u8, + PasswordRecoveryDeliveryKind::PasswordChanged => 2_u8, + }; + let mut aad = Vec::with_capacity(RECOVERY_AAD_DOMAIN.len() + 16 + 1 + 2); + aad.extend_from_slice(RECOVERY_AAD_DOMAIN); + aad.extend_from_slice(outbox_id.as_bytes()); + aad.push(kind_tag); + aad.extend_from_slice(&key_version.to_be_bytes()); + aad +} + +#[cfg(test)] +/// Unit tests for authenticated payloads and provider response handling. +mod tests { + use super::*; + use axum::http::StatusCode; + use axum::routing::post; + use axum::{Json, Router}; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; + use serde_json::{json, Value}; + use std::sync::{Arc, Mutex}; + + /// Build a complete server configuration around one deterministic delivery key. + fn recovery_config(key: [u8; 32]) -> ServerConfig { + let mut config = crate::config::test_support::minimal_test_config(); + config.cors_allowed_origins = "https://frameshift.test".to_string(); + config.first_party_auth.password_pepper = + SecretString::new("test-password-pepper".to_string()); + config.first_party_auth.mfa_encryption_key = + SecretString::new(URL_SAFE_NO_PAD.encode([17_u8; 32])); + config.first_party_auth.native_authorization_url = + "https://frameshift.test/account/".to_string(); + config.first_party_auth.recovery.enabled = true; + config.first_party_auth.recovery.provider_api_key = + SecretString::new("re_test_key".to_string()); + config.first_party_auth.recovery.from_address = + "FrameShift ".to_string(); + config.first_party_auth.recovery.reset_url = "https://frameshift.test/recover/".to_string(); + config.first_party_auth.recovery.delivery_key = + SecretString::new(URL_SAFE_NO_PAD.encode(key)); + config + } + + #[test] + /// Ciphertext round-trips only with its exact outbox metadata and key. + fn recovery_cipher_authenticates_metadata() { + let config = recovery_config([3_u8; 32]); + let cipher = RecoveryDeliveryCipher::from_config(&config) + .unwrap() + .expect("enabled cipher"); + let outbox_id = Uuid::new_v4(); + let encrypted = cipher + .encrypt_reset( + outbox_id, + "https://frameshift.test/recover/", + "secret-token", + ) + .unwrap(); + assert!(!encrypted + .ciphertext + .windows("secret-token".len()) + .any(|window| window == b"secret-token")); + + let plaintext = cipher + .decrypt( + outbox_id, + PasswordRecoveryDeliveryKind::Reset, + encrypted.key_version, + &encrypted.nonce, + &encrypted.ciphertext, + ) + .unwrap(); + let parsed = parse_recovery_delivery_payload(&plaintext).unwrap(); + assert!(matches!( + parsed, + RecoveryDeliveryPayload::Reset { + reset_url: "https://frameshift.test/recover/", + token: "secret-token" + } + )); + assert!(matches!( + cipher.decrypt( + Uuid::new_v4(), + PasswordRecoveryDeliveryKind::Reset, + encrypted.key_version, + &encrypted.nonce, + &encrypted.ciphertext, + ), + Err(RecoveryCryptoError::AuthenticationFailed) + )); + } + + #[tokio::test] + /// Resend dispatch includes a stable idempotency key and both message formats. + async fn resend_dispatch_uses_outbox_idempotency() { + let observed = Arc::new(Mutex::new(None::<(HeaderMap, Value)>)); + let server_observed = Arc::clone(&observed); + let app = Router::new().route( + "/emails", + post(move |headers: HeaderMap, Json(body): Json| { + let observed = Arc::clone(&server_observed); + async move { + *observed.lock().unwrap() = Some((headers, body)); + (StatusCode::OK, Json(json!({"id": "provider-message"}))) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/emails", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dispatcher = ResendRecoveryDispatcher::new( + endpoint, + SecretString::new("provider-secret".to_string()), + "FrameShift ".to_string(), + ) + .unwrap(); + let outbox_id = Uuid::new_v4(); + + let receipt = dispatcher + .deliver( + outbox_id, + "creator@example.test", + RecoveryDeliveryPayload::Reset { + reset_url: "https://frameshift.test/recover/", + token: "secret-token", + }, + ) + .await + .unwrap(); + assert_eq!(receipt.provider_message_id, "provider-message"); + let (headers, body) = observed.lock().unwrap().take().unwrap(); + assert_eq!(headers["idempotency-key"], outbox_id.to_string()); + assert_eq!(body["to"][0], "creator@example.test"); + assert!(body["text"] + .as_str() + .unwrap() + .contains("#token=secret-token")); + assert!(body["html"] + .as_str() + .unwrap() + .contains("#token=secret-token")); + server.abort(); + } + + #[tokio::test] + /// Provider throttling returns a capped retryable classification. + async fn resend_dispatch_honors_retry_after() { + let app = Router::new().route( + "/emails", + post(|| async { + ( + StatusCode::TOO_MANY_REQUESTS, + [("retry-after", "999999")], + Json(json!({"name": "rate_limit_exceeded"})), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/emails", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dispatcher = ResendRecoveryDispatcher::new( + endpoint, + SecretString::new("provider-secret".to_string()), + "recovery@frameshift.test".to_string(), + ) + .unwrap(); + + let error = dispatcher + .deliver( + Uuid::new_v4(), + "creator@example.test", + RecoveryDeliveryPayload::PasswordChanged, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + RecoveryDispatchError::Retryable { + retry_after: Some(delay), + .. + } if delay == MAX_PROVIDER_RETRY_AFTER + )); + server.abort(); + } + + #[tokio::test] + /// Invalid provider identifiers never reach the catalog acknowledgement path. + async fn resend_dispatch_rejects_invalid_success_identifier() { + let app = Router::new().route( + "/emails", + post(|| async { (StatusCode::OK, Json(json!({"id": ""}))) }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/emails", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let dispatcher = ResendRecoveryDispatcher::new( + endpoint, + SecretString::new("provider-secret".to_string()), + "recovery@frameshift.test".to_string(), + ) + .unwrap(); + + let error = dispatcher + .deliver( + Uuid::new_v4(), + "creator@example.test", + RecoveryDeliveryPayload::PasswordChanged, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + RecoveryDispatchError::Retryable { + reason: "invalid_success_response", + retry_after: None, + } + )); + server.abort(); + } +} diff --git a/crates/frameshift-server/src/router.rs b/crates/frameshift-server/src/router.rs index 80e5884..614b52c 100644 --- a/crates/frameshift-server/src/router.rs +++ b/crates/frameshift-server/src/router.rs @@ -67,7 +67,7 @@ use tower_http::request_id::{PropagateRequestIdLayer, SetRequestIdLayer}; use tower_http::set_header::SetResponseHeaderLayer; use crate::mcp::mcp_router; -use crate::middleware::account::{require_account, resolve_optional_account}; +use crate::middleware::account::{require_account, require_fresh_mfa, resolve_optional_account}; use crate::middleware::auth::require_signed_request; use crate::middleware::identity_limit::{ enforce_account_rate_limit, enforce_signer_rate_limit, IdentityRateLimits, @@ -85,7 +85,9 @@ use crate::routes::invite_admin::invite_admin_router; use crate::routes::invite_requests::invite_request_router; use crate::routes::local_auth::{local_auth_protected_router, local_auth_public_router}; use crate::routes::memory::memory_router; +use crate::routes::mfa::{mfa_fresh_router, mfa_protected_router, mfa_public_router}; use crate::routes::moderation::moderation_router; +use crate::routes::native_auth::{native_auth_protected_router, native_auth_public_router}; use crate::routes::ops::ops_router; use crate::routes::packs::{packs_router, publish_pack}; use crate::routes::publication_intents::publication_intent_router; @@ -240,7 +242,9 @@ fn build_app( if state.config.first_party_auth.enabled() { let local_auth = apply_ip_rate_limit( - local_auth_public_router(), + local_auth_public_router() + .merge(mfa_public_router()) + .merge(native_auth_public_router()), &state, state.config.abuse_rate_per_min, ); @@ -249,20 +253,43 @@ fn build_app( if state.account_auth.is_some() || state.config.first_party_auth.enabled() { let account_layer = axum::middleware::from_fn_with_state(state.clone(), require_account); + let fresh_mfa_layer = + axum::middleware::from_fn_with_state(state.clone(), require_fresh_mfa); + let fresh_auth_routes = mfa_fresh_router() + .merge(native_auth_protected_router()) + .route_layer(fresh_mfa_layer.clone()); + let local_protected_auth = local_auth_protected_router() + .merge(mfa_protected_router()) + .merge(fresh_auth_routes); let mut account_routes = account_write_router() - .merge(Router::new().nest("/auth", local_auth_protected_router())) - .merge(Router::new().nest("/admin", admin_router().merge(invite_admin_router()))) - .merge(Router::new().nest("/publish-intents", publication_intent_router())) + .route_layer(fresh_mfa_layer.clone()) + .merge(Router::new().nest("/auth", local_protected_auth)) + .merge( + Router::new().nest( + "/admin", + admin_router() + .merge(invite_admin_router()) + .route_layer(fresh_mfa_layer.clone()), + ), + ) .merge(Router::new().nest( - "/publication-submissions", - publication_submission_read_router(), + "/publish-intents", + publication_intent_router().route_layer(fresh_mfa_layer.clone()), )) .merge(Router::new().nest( - "/moderation/publication-submissions", - moderation_router(quarantine_review, publication_promotion), - )); + "/publication-submissions", + publication_submission_read_router().route_layer(fresh_mfa_layer.clone()), + )) + .merge( + Router::new().nest( + "/moderation/publication-submissions", + moderation_router(quarantine_review, publication_promotion) + .route_layer(fresh_mfa_layer.clone()), + ), + ); if let Some(admission) = publication_admission { let submission_writes = publication_submission_write_router(admission) + .route_layer(fresh_mfa_layer.clone()) .route_layer(signer_limit.clone()) .route_layer(signed.clone()); let submission_writes = diff --git a/crates/frameshift-server/src/routes/accounts.rs b/crates/frameshift-server/src/routes/accounts.rs index 880fe1f..d2c0823 100644 --- a/crates/frameshift-server/src/routes/accounts.rs +++ b/crates/frameshift-server/src/routes/accounts.rs @@ -65,6 +65,8 @@ pub struct AuthConfigResponse { pub first_party_enabled: bool, /// Stable first-party registration policy. pub registration: &'static str, + /// Credential-free HTTPS portal used for native first-party authorization. + pub native_authorization_url: Option, /// Configured OIDC issuer when enabled. pub issuer: Option, /// Configured resource audience when enabled. @@ -76,6 +78,8 @@ pub struct AuthConfigResponse { pub struct AccountResponse { /// Durable account record. pub account: AccountRecord, + /// Whether this account currently has an active MFA authenticator. + pub mfa_enabled: bool, /// Publisher memberships held by the account. pub memberships: Vec, /// Publisher profiles aligned with memberships in the same stable order. @@ -307,6 +311,7 @@ async fn get_auth_config(State(state): State) -> Json, Extension(auth): Extension, ) -> Result, AppError> { + let mfa_enabled = match state + .catalog + .get_active_account_mfa_authenticator(auth.account.id) + .await + { + Ok(_) => true, + Err(CatalogError::NotFound { .. }) => false, + Err(error) => return Err(AppError::from_catalog(error, "MFA authenticator")), + }; let memberships = state .catalog .list_account_memberships(auth.account.id) @@ -334,6 +348,7 @@ async fn get_account( } Ok(Json(AccountResponse { account: auth.account, + mfa_enabled, memberships, publishers, })) diff --git a/crates/frameshift-server/src/routes/local_auth.rs b/crates/frameshift-server/src/routes/local_auth.rs index 6967b77..c93316a 100644 --- a/crates/frameshift-server/src/routes/local_auth.rs +++ b/crates/frameshift-server/src/routes/local_auth.rs @@ -1,33 +1,42 @@ //! Invite-bound first-party registration, password login, and session logout. +use std::sync::Arc; + use axum::extract::State; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::post; use axum::{Extension, Json, Router}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; use chrono::{DateTime, Duration, Utc}; use frameshift_catalog::{ - AccountPasswordCredentialRecord, AccountPasswordRehashRequest, AccountRecord, - AccountSessionClientKind, AccountSessionRecord, AccountStatus, CatalogError, - LocalAccountRegistrationRequest, + AccountAuthAuditEventKind, AccountAuthAuditOutcome, AccountMfaChallengeCreationRequest, + AccountMfaLoginChallengeRecord, AccountPasswordCredentialRecord, AccountPasswordRehashRequest, + AccountRecord, AccountSessionClientKind, AccountSessionCreationRequest, AccountSessionRecord, + AccountSessionRefreshRequest, AccountSessionRefreshResult, AccountStatus, CatalogError, + EncryptedPasswordRecoveryDelivery, LocalAccountRegistrationRequest, + PasswordRecoveryCompletionRequest, PasswordRecoveryEnqueueRequest, }; -use rand_core::{OsRng, RngCore as _}; -use secrecy::SecretString; +use secrecy::{ExposeSecret as _, SecretString}; use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; use tokio::sync::{Semaphore, SemaphorePermit}; use uuid::Uuid; +use zeroize::Zeroizing; use crate::error::AppError; +use crate::first_party_auth::{ + add_std_duration, auth_audit_event, decode_access_token, decode_refresh_token, + generate_access_token, generate_bound_token, generate_refresh_token, identifier_tag, + issue_session, AuthAuditContext, +}; use crate::middleware::account::AuthenticatedAccount; use crate::password_auth::{PasswordAuthError, PasswordService}; +use crate::password_blocklist; +use crate::recovery_delivery::RecoveryDeliveryCipher; use crate::routes::invite_requests::{normalize_email, normalize_optional_display_name}; use crate::state::AppState; /// Minimum accepted password length in Unicode scalar values. -const MIN_PASSWORD_CHARS: usize = 12; +const MIN_PASSWORD_CHARS: usize = 15; /// Maximum accepted password size before Argon2 processing. const MAX_PASSWORD_BYTES: usize = 1_024; /// Tight body limit for registration and login credentials. @@ -36,18 +45,13 @@ const MAX_LOCAL_AUTH_BYTES: usize = 8 * 1_024; const PASSWORD_VERSION: i16 = 1; /// Process-wide cap on concurrent 64 MiB Argon2id operations. static PASSWORD_WORK_SLOTS: Semaphore = Semaphore::const_new(2); +/// Minimum wall-clock duration for every valid recovery-request response. +const MIN_RECOVERY_REQUEST_DURATION: std::time::Duration = std::time::Duration::from_millis(250); +/// Maximum time allowed for delivery of the post-change notification. +const PASSWORD_CHANGED_DELIVERY_TTL: Duration = Duration::hours(24); -/// Browser or explicit bearer presentation selected by the client. -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum LocalAuthClientKind { - /// Browser receives a secure HTTP-only cookie. - Browser, - /// Desktop application receives an explicit bearer token. - Desktop, - /// Command-line client receives an explicit bearer token. - Cli, -} +/// Backward-compatible name for the catalog-owned session client class. +pub type LocalAuthClientKind = AccountSessionClientKind; /// Browser-submitted fields for invite redemption and account creation. #[derive(Deserialize)] @@ -77,14 +81,68 @@ pub struct LoginLocalAccountRequest { pub client_kind: LocalAuthClientKind, } -/// Successful local registration or login response. +/// Browser request for an indistinguishable password-recovery email response. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestPasswordRecoveryRequest { + /// Account email normalized only inside the server boundary. + pub email: String, +} + +/// Browser submission of one reset bearer and replacement password. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompletePasswordRecoveryRequest { + /// Opaque single-use reset bearer received in an email URL fragment. + pub token: String, + /// Replacement password processed under the new-password policy. + pub password: String, +} + +/// Refresh-token input selected by transport class. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RefreshLocalSessionRequest { + /// Browser, desktop, or CLI presentation required by the caller. + pub client_kind: LocalAuthClientKind, + /// Rotating raw refresh token accepted only from desktop and CLI clients. + pub refresh_token: Option, +} + +/// Generic recovery-request acknowledgement shared by known and unknown emails. +#[derive(Debug, Serialize)] +pub struct PasswordRecoveryAcceptedResponse { + /// Stable indication that the bounded request was accepted for processing. + pub accepted: bool, +} + +/// Successful first-party session issuance or refresh response. #[derive(Debug, Serialize)] pub struct LocalAuthResponse { /// Durable authenticated account. pub account: AccountRecord, - /// Raw bearer token returned only to desktop and CLI clients. - pub token: Option, - /// Non-extendable session expiry. + /// Raw access token returned only to desktop and CLI clients. + pub access_token: Option, + /// Raw refresh token returned only to desktop and CLI clients. + pub refresh_token: Option, + /// Stable OAuth bearer-token presentation type. + pub token_type: &'static str, + /// Short-lived access-token expiry. + pub expires_at: DateTime, + /// Current refresh-token generation expiry. + pub refresh_expires_at: DateTime, + /// Non-extendable session-family expiry. + pub session_expires_at: DateTime, +} + +/// Password-login response requiring browser-side second-factor completion. +#[derive(Debug, Serialize)] +pub struct MfaChallengeRequiredResponse { + /// Stable signal that no session was issued yet. + pub mfa_required: bool, + /// Opaque one-time challenge bearer. + pub challenge_token: String, + /// Exclusive challenge completion deadline. pub expires_at: DateTime, } @@ -100,6 +158,15 @@ pub fn local_auth_public_router() -> Router { Router::new() .route("/register", post(register_local_account)) .route("/login", post(login_local_account)) + .route("/refresh", post(refresh_local_session)) + .route( + "/password-recovery/request", + post(request_password_recovery), + ) + .route( + "/password-recovery/complete", + post(complete_password_recovery), + ) .layer(tower_http::limit::RequestBodyLimitLayer::new( MAX_LOCAL_AUTH_BYTES, )) @@ -117,18 +184,28 @@ async fn register_local_account( Json(request): Json, ) -> Result { require_local_auth_enabled(&state)?; - if matches!(request.client_kind, LocalAuthClientKind::Browser) { - require_trusted_browser_origin(&state, &headers)?; + if request.client_kind != LocalAuthClientKind::Browser { + return Err(AppError::BadRequest( + "password registration is available only in the trusted browser portal".into(), + )); } + require_trusted_browser_origin(&state, &headers)?; let invite_digest = decode_and_digest_token(&request.invite_token)?; let normalized_email = normalize_email(&request.email)?; + let registration_identifier_tag = + identifier_tag(&state.config.first_party_auth, &normalized_email); let display_name = normalize_optional_display_name(request.display_name)?; - let password = protected_password(request.password)?; + let password = protected_new_password(request.password)?; let password_hash = hash_password(&state, password).await?; - let (session_token, session_digest) = generate_token(); let now = Utc::now(); let account_id = Uuid::new_v4(); - let session = build_session(&state, account_id, session_digest, request.client_kind, now)?; + let issued = issue_session( + &state.config.first_party_auth, + account_id, + request.client_kind, + now, + None, + )?; let account = AccountRecord { id: account_id, issuer: state.config.first_party_auth.issuer.clone(), @@ -155,7 +232,19 @@ async fn register_local_account( password_changed_at: now, updated_at: now, }, - session: session.clone(), + session: issued.issuance.clone(), + audit_event: auth_audit_event( + AccountAuthAuditEventKind::SessionCreated, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(account_id), + session_id: Some(issued.issuance.session.id), + client_kind: Some(request.client_kind), + identifier_tag: Some(registration_identifier_tag), + reason_code: None, + }, + now, + ), }) .await .map_err(map_registration_error)?; @@ -164,7 +253,9 @@ async fn register_local_account( result.account, result.session, request.client_kind, - session_token, + issued.access_token, + issued.refresh_token, + issued.issuance.refresh_expires_at, ) } @@ -175,11 +266,15 @@ async fn login_local_account( Json(request): Json, ) -> Result { require_local_auth_enabled(&state)?; - if matches!(request.client_kind, LocalAuthClientKind::Browser) { - require_trusted_browser_origin(&state, &headers)?; + if request.client_kind != LocalAuthClientKind::Browser { + return Err(AppError::BadRequest( + "password login is available only in the trusted browser portal".into(), + )); } + require_trusted_browser_origin(&state, &headers)?; let normalized_email = normalize_email(&request.email)?; - let password = protected_password(request.password)?; + let login_identifier_tag = identifier_tag(&state.config.first_party_auth, &normalized_email); + let password = protected_login_password(request.password)?; let credential = match state .catalog .get_account_password_credential(&normalized_email) @@ -191,29 +286,399 @@ async fn login_local_account( }; let password_matches = verify_or_absorb_password_work(&state, password.clone(), credential.as_ref()).await?; - let credential = credential - .filter(|_| password_matches) - .ok_or_else(|| AppError::Unauthorized("email or password is incorrect".to_string()))?; + let Some(credential) = credential.filter(|_| password_matches) else { + append_rejection_audit( + &state, + None, + None, + Some(request.client_kind), + Some(login_identifier_tag), + "invalid_password", + ) + .await; + return Err(AppError::Unauthorized( + "email or password is incorrect".to_string(), + )); + }; let account = state .catalog .get_account(credential.account_id) .await .map_err(|error| AppError::from_catalog(error, "account"))?; if account.status != AccountStatus::Active { + append_rejection_audit( + &state, + Some(account.id), + None, + Some(request.client_kind), + Some(login_identifier_tag), + "inactive_account", + ) + .await; return Err(AppError::Unauthorized( "email or password is incorrect".to_string(), )); } rehash_password_if_rotated(&state, &password, &credential).await?; - let (session_token, session_digest) = generate_token(); let now = Utc::now(); - let session = build_session(&state, account.id, session_digest, request.client_kind, now)?; - state + match state .catalog - .create_account_session(session.clone()) + .get_active_account_mfa_authenticator(account.id) .await - .map_err(|error| AppError::from_catalog(error, "account session"))?; - local_auth_response(&state, account, session, request.client_kind, session_token) + { + Ok(_) => { + let (challenge_token, challenge_digest) = + generate_bound_token(account.id, request.client_kind); + let expires_at = add_std_duration( + now, + state.config.first_party_auth.mfa_challenge_ttl, + "MFA challenge TTL", + )?; + state + .catalog + .create_account_mfa_challenge(AccountMfaChallengeCreationRequest { + challenge: AccountMfaLoginChallengeRecord { + id: Uuid::new_v4(), + account_id: account.id, + token_digest: challenge_digest, + client_kind: request.client_kind, + created_at: now, + expires_at, + consumed_at: None, + }, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::MfaChallengeCreated, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(account.id), + client_kind: Some(request.client_kind), + identifier_tag: Some(login_identifier_tag), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "MFA challenge"))?; + Ok(( + StatusCode::ACCEPTED, + Json(MfaChallengeRequiredResponse { + mfa_required: true, + challenge_token, + expires_at, + }), + ) + .into_response()) + } + Err(CatalogError::NotFound { .. }) => { + let issued = issue_session( + &state.config.first_party_auth, + account.id, + request.client_kind, + now, + None, + )?; + let session = state + .catalog + .create_account_session(AccountSessionCreationRequest { + issuance: issued.issuance.clone(), + audit_event: auth_audit_event( + AccountAuthAuditEventKind::SessionCreated, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(account.id), + session_id: Some(issued.issuance.session.id), + client_kind: Some(request.client_kind), + identifier_tag: Some(login_identifier_tag), + reason_code: None, + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "account session"))?; + local_auth_response( + &state, + account, + session, + request.client_kind, + issued.access_token, + issued.refresh_token, + issued.issuance.refresh_expires_at, + ) + } + Err(error) => Err(AppError::from_catalog(error, "MFA authenticator")), + } +} + +/// Rotate one refresh generation and replace the short-lived access token. +async fn refresh_local_session( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + require_local_auth_enabled(&state)?; + let raw_refresh = match request.client_kind { + LocalAuthClientKind::Browser => { + require_trusted_browser_origin(&state, &headers)?; + if request.refresh_token.is_some() { + return Err(AppError::BadRequest( + "browser refresh tokens are accepted only from the secure cookie".into(), + )); + } + extract_named_cookie(&headers, &state.config.first_party_auth.refresh_cookie_name) + .ok_or_else(|| { + AppError::Unauthorized("refresh token is invalid or expired".into()) + })? + .to_string() + } + LocalAuthClientKind::Desktop | LocalAuthClientKind::Cli => request + .refresh_token + .ok_or_else(|| AppError::Unauthorized("refresh token is invalid or expired".into()))?, + }; + let raw_refresh = Zeroizing::new(raw_refresh); + let decoded = decode_refresh_token(raw_refresh.as_str())?; + if decoded.client_kind != request.client_kind { + append_rejection_audit( + &state, + Some(decoded.account_id), + Some(decoded.session_id), + Some(request.client_kind), + None, + "refresh_client_mismatch", + ) + .await; + return Err(AppError::Unauthorized( + "refresh token is invalid or expired".into(), + )); + } + let now = Utc::now(); + if decoded.absolute_expires_at <= now { + append_rejection_audit( + &state, + Some(decoded.account_id), + Some(decoded.session_id), + Some(decoded.client_kind), + None, + "refresh_expired", + ) + .await; + return Err(AppError::Unauthorized( + "refresh token is invalid or expired".into(), + )); + } + let (replacement_access_token, replacement_access_digest) = generate_access_token(); + let (replacement_refresh_token, replacement_refresh_digest) = generate_refresh_token( + decoded.account_id, + decoded.session_id, + decoded.absolute_expires_at, + decoded.client_kind, + ); + let idle_ttl = match decoded.client_kind { + AccountSessionClientKind::Browser => state.config.first_party_auth.browser_idle_ttl, + AccountSessionClientKind::Desktop | AccountSessionClientKind::Cli => { + state.config.first_party_auth.bearer_idle_ttl + } + }; + let replacement_access_expires_at = std::cmp::min( + add_std_duration( + now, + state.config.first_party_auth.access_ttl, + "access-token TTL", + )?, + decoded.absolute_expires_at, + ); + let replacement_idle_expires_at = std::cmp::min( + add_std_duration(now, idle_ttl, "session idle TTL")?, + decoded.absolute_expires_at, + ); + let replacement_refresh_expires_at = std::cmp::min( + add_std_duration( + now, + state.config.first_party_auth.refresh_ttl, + "refresh-token TTL", + )?, + replacement_idle_expires_at, + ); + let result = state + .catalog + .refresh_account_session(AccountSessionRefreshRequest { + presented_refresh_token_digest: decoded.digest, + replacement_access_token_digest: replacement_access_digest, + replacement_access_expires_at, + replacement_idle_expires_at, + replacement_refresh_token_id: Uuid::new_v4(), + replacement_refresh_token_digest: replacement_refresh_digest, + replacement_refresh_expires_at, + rotated_at: now, + success_audit_event: auth_audit_event( + AccountAuthAuditEventKind::SessionRefreshed, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(decoded.account_id), + session_id: Some(decoded.session_id), + client_kind: Some(decoded.client_kind), + ..AuthAuditContext::default() + }, + now, + ), + replay_audit_event: auth_audit_event( + AccountAuthAuditEventKind::SessionReplayRevoked, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(decoded.account_id), + session_id: Some(decoded.session_id), + client_kind: Some(decoded.client_kind), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "account session refresh"))?; + match result { + AccountSessionRefreshResult::Rotated(session) => { + let account = state + .catalog + .get_account(session.account_id) + .await + .map_err(|error| AppError::from_catalog(error, "account"))?; + if account.status != AccountStatus::Active { + return Err(AppError::Unauthorized( + "refresh token is invalid or expired".into(), + )); + } + local_auth_response( + &state, + account, + session, + request.client_kind, + replacement_access_token, + replacement_refresh_token, + replacement_refresh_expires_at, + ) + } + AccountSessionRefreshResult::ReplayRevoked => Err(AppError::Unauthorized( + "refresh token is invalid or expired".into(), + )), + AccountSessionRefreshResult::Rejected => { + append_rejection_audit( + &state, + Some(decoded.account_id), + Some(decoded.session_id), + Some(decoded.client_kind), + None, + "refresh_rejected", + ) + .await; + Err(AppError::Unauthorized( + "refresh token is invalid or expired".into(), + )) + } + } +} + +/// Enqueue one encrypted reset delivery without disclosing account existence. +async fn request_password_recovery( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + require_local_auth_enabled(&state)?; + require_trusted_browser_origin(&state, &headers)?; + let cipher = require_password_recovery_enabled(&state)?; + let started_at = tokio::time::Instant::now(); + let normalized_email = normalize_email(&request.email)?; + let (raw_token, token_digest) = generate_token(); + let raw_token = Zeroizing::new(raw_token); + let token_id = Uuid::new_v4(); + let delivery_id = Uuid::new_v4(); + let encrypted = cipher + .encrypt_reset( + delivery_id, + &state.config.first_party_auth.recovery.reset_url, + raw_token.as_str(), + ) + .map_err(|_| AppError::Internal("password recovery encryption failed".to_string()))?; + let requested_at = Utc::now(); + let token_ttl = Duration::from_std(state.config.first_party_auth.recovery.token_ttl) + .map_err(|_| AppError::Internal("password recovery token TTL is invalid".to_string()))?; + let cooldown = Duration::from_std(state.config.first_party_auth.recovery.request_cooldown) + .map_err(|_| AppError::Internal("password recovery cooldown is invalid".to_string()))?; + let token_expires_at = requested_at + token_ttl; + let catalog = Arc::clone(&state.catalog); + tokio::spawn(async move { + if catalog + .enqueue_account_password_recovery(PasswordRecoveryEnqueueRequest { + token_id, + normalized_email, + token_digest, + requested_at, + token_expires_at, + cooldown_cutoff: requested_at - cooldown, + delivery: EncryptedPasswordRecoveryDelivery { + id: delivery_id, + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce, + key_version: encrypted.key_version, + expires_at: token_expires_at, + }, + }) + .await + .is_err() + { + tracing::warn!("password recovery enqueue failed"); + } + }); + tokio::time::sleep_until(started_at + MIN_RECOVERY_REQUEST_DURATION).await; + Ok(( + StatusCode::ACCEPTED, + Json(PasswordRecoveryAcceptedResponse { accepted: true }), + ) + .into_response()) +} + +/// Consume one reset bearer, change its credential, and revoke every session. +async fn complete_password_recovery( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + require_local_auth_enabled(&state)?; + require_trusted_browser_origin(&state, &headers)?; + let cipher = require_password_recovery_enabled(&state)?; + let raw_token = Zeroizing::new(request.token); + let token_digest = + decode_and_digest_token(raw_token.as_str()).map_err(|_| invalid_recovery_token_error())?; + let password = protected_new_password(request.password)?; + let new_password_hash = hash_password(&state, password).await?; + let completed_at = Utc::now(); + let delivery_id = Uuid::new_v4(); + let encrypted = cipher + .encrypt_password_changed(delivery_id) + .map_err(|_| AppError::Internal("password recovery encryption failed".to_string()))?; + let completed = state + .catalog + .complete_account_password_recovery(PasswordRecoveryCompletionRequest { + token_digest, + new_password_hash, + new_password_version: PASSWORD_VERSION, + new_pepper_version: state.config.first_party_auth.pepper_version, + completed_at, + delivery: EncryptedPasswordRecoveryDelivery { + id: delivery_id, + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce, + key_version: encrypted.key_version, + expires_at: completed_at + PASSWORD_CHANGED_DELIVERY_TTL, + }, + }) + .await + .map_err(map_recovery_catalog_error)?; + if !completed { + return Err(invalid_recovery_token_error()); + } + Ok(StatusCode::NO_CONTENT.into_response()) } /// Revoke the current local session and clear its browser cookie when applicable. @@ -235,15 +700,11 @@ async fn logout_local_account( ) .into_response(); if auth.via_cookie { - let cleared = format!( - "{}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0", - state.config.first_party_auth.cookie_name - ); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_str(&cleared) - .map_err(|_| AppError::Internal("invalid session cookie configuration".into()))?, - ); + append_cleared_cookie(&mut response, &state.config.first_party_auth.cookie_name)?; + append_cleared_cookie( + &mut response, + &state.config.first_party_auth.refresh_cookie_name, + )?; } Ok(response) } @@ -259,8 +720,21 @@ fn require_local_auth_enabled(state: &AppState) -> Result<(), AppError> { } } -/// Require an exact configured browser origin before cookie creation. -fn require_trusted_browser_origin(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> { +/// Return a validated recovery cipher or one uniform unavailable response. +fn require_password_recovery_enabled(state: &AppState) -> Result { + RecoveryDeliveryCipher::from_config(&state.config) + .ok() + .flatten() + .ok_or_else(|| { + AppError::ServiceUnavailable("password recovery is not configured".to_string()) + }) +} + +/// Require an exact configured browser origin before cookie creation or mutation. +pub(crate) fn require_trusted_browser_origin( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), AppError> { let origin = headers .get(header::ORIGIN) .and_then(|value| value.to_str().ok()) @@ -277,15 +751,33 @@ fn require_trusted_browser_origin(state: &AppState, headers: &HeaderMap) -> Resu Ok(()) } -/// Validate password bounds and move the value into protected memory. -fn protected_password(password: String) -> Result { - let character_count = password.chars().count(); - if character_count < MIN_PASSWORD_CHARS || password.len() > MAX_PASSWORD_BYTES { +/// Validate a newly created password and move its exact bytes into protected memory. +pub(crate) fn protected_new_password(password: String) -> Result { + let password = SecretString::new(password); + let exposed = password.expose_secret(); + let character_count = exposed.chars().count(); + if character_count < MIN_PASSWORD_CHARS || exposed.len() > MAX_PASSWORD_BYTES { return Err(AppError::BadRequest(format!( "password must contain at least {MIN_PASSWORD_CHARS} characters and at most {MAX_PASSWORD_BYTES} bytes" ))); } - Ok(SecretString::new(password)) + if password_blocklist::is_blocklisted(exposed) { + return Err(AppError::BadRequest( + "password is too common or specific to FrameShift".to_string(), + )); + } + Ok(password) +} + +/// Bound a login password without applying creation policy to legacy credentials. +fn protected_login_password(password: String) -> Result { + let password = SecretString::new(password); + if password.expose_secret().len() > MAX_PASSWORD_BYTES { + return Err(AppError::BadRequest(format!( + "password must contain at most {MAX_PASSWORD_BYTES} bytes" + ))); + } + Ok(password) } /// Hash one password on the blocking pool using the deployment pepper. @@ -434,104 +926,141 @@ fn map_registration_error(error: CatalogError) -> AppError { } } +/// Hide all catalog details from the public recovery surface. +fn map_recovery_catalog_error(_error: CatalogError) -> AppError { + AppError::Internal("password recovery catalog operation failed".to_string()) +} + +/// Return the single public error shared by every unusable recovery bearer. +fn invalid_recovery_token_error() -> AppError { + AppError::BadRequest("password recovery token is invalid or expired".to_string()) +} + /// Generate one random 256-bit token and its SHA-256 digest. pub(crate) fn generate_token() -> (String, Vec) { - let mut raw = [0_u8; 32]; - OsRng.fill_bytes(&mut raw); - (URL_SAFE_NO_PAD.encode(raw), Sha256::digest(raw).to_vec()) + generate_access_token() } /// Decode one canonical 256-bit token and return its SHA-256 digest. pub(crate) fn decode_and_digest_token(token: &str) -> Result, AppError> { - if token.len() > 128 || token.chars().any(char::is_whitespace) { - return Err(AppError::Unauthorized( - "token is invalid or expired".to_string(), - )); - } - let raw = URL_SAFE_NO_PAD - .decode(token) - .map_err(|_| AppError::Unauthorized("token is invalid or expired".to_string()))?; - if raw.len() != 32 || URL_SAFE_NO_PAD.encode(&raw) != token { - return Err(AppError::Unauthorized( - "token is invalid or expired".to_string(), - )); - } - Ok(Sha256::digest(raw).to_vec()) + decode_access_token(token) } -/// Build one session with transport-specific idle duration and a shared absolute cap. -fn build_session( +/// Append one standalone sanitized rejection audit without changing its public response. +pub(crate) async fn append_rejection_audit( state: &AppState, - account_id: Uuid, - token_digest: Vec, - client_kind: LocalAuthClientKind, - now: DateTime, -) -> Result { - let (client_kind, idle_ttl) = match client_kind { - LocalAuthClientKind::Browser => ( - AccountSessionClientKind::Browser, - state.config.first_party_auth.browser_idle_ttl, - ), - LocalAuthClientKind::Desktop => ( - AccountSessionClientKind::Desktop, - state.config.first_party_auth.bearer_idle_ttl, - ), - LocalAuthClientKind::Cli => ( - AccountSessionClientKind::Cli, - state.config.first_party_auth.bearer_idle_ttl, - ), - }; - let idle_ttl = Duration::from_std(idle_ttl) - .map_err(|_| AppError::Internal("session idle duration is invalid".to_string()))?; - let absolute_ttl = Duration::from_std(state.config.first_party_auth.absolute_ttl) - .map_err(|_| AppError::Internal("session absolute duration is invalid".to_string()))?; - Ok(AccountSessionRecord { - id: Uuid::new_v4(), - account_id, - token_digest, - client_kind, - created_at: now, - last_seen_at: now, - idle_expires_at: now + idle_ttl, - absolute_expires_at: now + absolute_ttl, - revoked_at: None, - }) + account_id: Option, + session_id: Option, + client_kind: Option, + identifier_tag: Option>, + reason_code: &'static str, +) { + let event = auth_audit_event( + AccountAuthAuditEventKind::AuthenticationRejected, + AccountAuthAuditOutcome::Rejected, + AuthAuditContext { + account_id, + session_id, + client_kind, + identifier_tag, + reason_code: Some(reason_code), + }, + Utc::now(), + ); + if state + .catalog + .append_account_auth_audit_event(event) + .await + .is_err() + { + tracing::warn!(reason_code, "authentication rejection audit failed"); + } } /// Build the transport-specific successful authentication response. -fn local_auth_response( +pub(crate) fn local_auth_response( state: &AppState, account: AccountRecord, session: AccountSessionRecord, client_kind: LocalAuthClientKind, - raw_token: String, + access_token: String, + refresh_token: String, + refresh_expires_at: DateTime, ) -> Result { - let explicit_token = - (!matches!(client_kind, LocalAuthClientKind::Browser)).then(|| raw_token.clone()); + let explicit_access_token = + (client_kind != LocalAuthClientKind::Browser).then(|| access_token.clone()); + let explicit_refresh_token = + (client_kind != LocalAuthClientKind::Browser).then(|| refresh_token.clone()); let mut response = ( StatusCode::OK, Json(LocalAuthResponse { account, - token: explicit_token, - expires_at: session.absolute_expires_at, + access_token: explicit_access_token, + refresh_token: explicit_refresh_token, + token_type: "Bearer", + expires_at: session.access_expires_at, + refresh_expires_at, + session_expires_at: session.absolute_expires_at, }), ) .into_response(); - if matches!(client_kind, LocalAuthClientKind::Browser) { - let max_age = state.config.first_party_auth.absolute_ttl.as_secs(); - let cookie = format!( - "{}={raw_token}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age={max_age}", - state.config.first_party_auth.cookie_name - ); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_str(&cookie) - .map_err(|_| AppError::Internal("invalid session cookie configuration".into()))?, - ); + if client_kind == LocalAuthClientKind::Browser { + let now = Utc::now(); + append_session_cookie( + &mut response, + &state.config.first_party_auth.cookie_name, + &access_token, + (session.access_expires_at - now).num_seconds().max(0), + )?; + append_session_cookie( + &mut response, + &state.config.first_party_auth.refresh_cookie_name, + &refresh_token, + (refresh_expires_at - now).num_seconds().max(0), + )?; } Ok(response) } +/// Extract exactly one non-empty named cookie and reject duplicate-name ambiguity. +pub(crate) fn extract_named_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let raw = headers.get(header::COOKIE)?.to_str().ok()?; + let mut matches = raw.split(';').filter_map(|part| { + let (candidate, value) = part.trim().split_once('=')?; + (candidate == name && !value.is_empty()).then_some(value) + }); + let token = matches.next()?; + matches.next().is_none().then_some(token) +} + +/// Append one Secure, HTTP-only, Strict browser session cookie. +fn append_session_cookie( + response: &mut Response, + name: &str, + value: &str, + max_age: i64, +) -> Result<(), AppError> { + let cookie = + format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age={max_age}"); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&cookie) + .map_err(|_| AppError::Internal("invalid session cookie configuration".into()))?, + ); + Ok(()) +} + +/// Append one expired Secure, HTTP-only, Strict browser cookie. +fn append_cleared_cookie(response: &mut Response, name: &str) -> Result<(), AppError> { + let cookie = format!("{name}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0"); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&cookie) + .map_err(|_| AppError::Internal("invalid session cookie configuration".into()))?, + ); + Ok(()) +} + #[cfg(test)] /// Unit tests for token canonicalization and password input bounds. mod tests { @@ -551,11 +1080,27 @@ mod tests { assert!(decode_and_digest_token(&format!("{}=", "a".repeat(43))).is_err()); } - /// Password bounds reject short and oversized inputs. + /// New-password bounds count Unicode scalars and cap UTF-8 bytes independently. + #[test] + fn new_password_bounds_are_enforced() { + assert!(protected_new_password("é".repeat(MIN_PASSWORD_CHARS - 1)).is_err()); + assert!(protected_new_password("é".repeat(MIN_PASSWORD_CHARS)).is_ok()); + assert!(protected_new_password("x".repeat(MAX_PASSWORD_BYTES)).is_ok()); + assert!(protected_new_password("x".repeat(MAX_PASSWORD_BYTES + 1)).is_err()); + } + + /// New-password policy rejects blocklisted values after comparison normalization. + #[test] + fn new_password_blocklist_is_enforced() { + assert!(protected_new_password(" FrameShiftPassword ".to_string()).is_err()); + assert!(protected_new_password("correct horse battery staple".to_string()).is_ok()); + } + + /// Login accepts legacy short inputs but retains the Argon2 abuse bound. #[test] - fn password_bounds_are_enforced() { - assert!(protected_password("short".to_string()).is_err()); - assert!(protected_password("x".repeat(MAX_PASSWORD_BYTES + 1)).is_err()); - assert!(protected_password("correct horse battery staple".to_string()).is_ok()); + fn login_password_preserves_legacy_length_compatibility() { + assert!(protected_login_password(String::new()).is_ok()); + assert!(protected_login_password("short".to_string()).is_ok()); + assert!(protected_login_password("x".repeat(MAX_PASSWORD_BYTES + 1)).is_err()); } } diff --git a/crates/frameshift-server/src/routes/mfa.rs b/crates/frameshift-server/src/routes/mfa.rs new file mode 100644 index 0000000..c3093b1 --- /dev/null +++ b/crates/frameshift-server/src/routes/mfa.rs @@ -0,0 +1,472 @@ +//! First-party TOTP enrollment, activation, challenge completion, and disable routes. + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Extension, Json, Router}; +use chrono::{DateTime, Utc}; +use frameshift_catalog::{ + AccountAuthAuditEventKind, AccountAuthAuditOutcome, AccountMfaActivationRequest, + AccountMfaAuthenticatorRecord, AccountMfaAuthenticatorState, + AccountMfaChallengeCompletionRequest, AccountMfaChallengeCompletionResult, + AccountMfaChallengeProof, AccountMfaDisableRequest, AccountMfaEnrollmentRequest, + AccountSessionClientKind, CatalogError, +}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::error::AppError; +use crate::first_party_auth::{ + add_std_duration, auth_audit_event, base32_no_pad, decode_bound_token, decode_recovery_code, + generate_recovery_codes, generate_totp_secret, issue_session, verify_totp, AuthAuditContext, + MfaSecretCipher, MFA_RECOVERY_CODE_COUNT, +}; +use crate::middleware::account::{validate_fresh_authentication, AuthenticatedAccount}; +use crate::routes::local_auth::{ + append_rejection_audit, local_auth_response, require_trusted_browser_origin, +}; +use crate::state::AppState; + +/// Tight body limit for MFA proofs and one-time challenge tokens. +const MAX_MFA_BODY_BYTES: usize = 4 * 1_024; + +/// TOTP enrollment metadata shown only while beginning enrollment. +#[derive(Debug, Serialize)] +pub struct BeginMfaEnrollmentResponse { + /// Stable identifier required to activate this exact pending enrollment. + pub authenticator_id: Uuid, + /// Base32 TOTP seed shown for enrollment and never returned again. + pub secret: String, + /// Standards-compatible HMAC-SHA256 authenticator URI. + pub otpauth_uri: String, + /// Exclusive pending-enrollment deadline. + pub expires_at: DateTime, +} + +/// Proof-of-possession input for one pending TOTP enrollment. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivateMfaRequest { + /// Canonical UUID string identifying the pending authenticator. + pub authenticator_id: String, + /// Current six-digit TOTP code. + pub totp_code: String, +} + +/// One-time recovery-code delivery returned only after activation. +#[derive(Debug, Serialize)] +pub struct ActivateMfaResponse { + /// Stable signal that the authenticator is active. + pub enabled: bool, + /// High-entropy recovery codes shown exactly once. + pub recovery_codes: Vec, +} + +/// Browser-side proof used to finish a password-bound login challenge. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompleteMfaChallengeRequest { + /// Opaque one-time challenge token returned after password verification. + pub challenge_token: String, + /// Browser presentation binding required by the password flow. + pub client_kind: AccountSessionClientKind, + /// Optional current six-digit TOTP code. + pub totp_code: Option, + /// Optional high-entropy one-time recovery code. + pub recovery_code: Option, +} + +/// Stable response after an active authenticator is disabled. +#[derive(Debug, Serialize)] +pub struct DisableMfaResponse { + /// Stable signal that MFA is no longer active. + pub enabled: bool, +} + +/// Build the unauthenticated MFA challenge-completion endpoint. +pub fn mfa_public_router() -> Router { + Router::new() + .route("/mfa/challenge/complete", post(complete_mfa_challenge)) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + MAX_MFA_BODY_BYTES, + )) +} + +/// Build protected browser MFA lifecycle endpoints. +pub fn mfa_protected_router() -> Router { + Router::new() + .route("/mfa/enroll", post(begin_mfa_enrollment)) + .route("/mfa/activate", post(activate_mfa)) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + MAX_MFA_BODY_BYTES, + )) +} + +/// Build the protected MFA endpoint that itself requires fresh assurance. +pub fn mfa_fresh_router() -> Router { + Router::new() + .route("/mfa/disable", post(disable_mfa)) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + MAX_MFA_BODY_BYTES, + )) +} + +/// Begin one encrypted pending TOTP enrollment for the authenticated browser account. +async fn begin_mfa_enrollment( + State(state): State, + headers: HeaderMap, + Extension(auth): Extension, +) -> Result { + require_browser_session(&auth)?; + require_trusted_browser_origin(&state, &headers)?; + require_fresh_mfa_for_replacement(&state, &auth).await?; + let cipher = MfaSecretCipher::from_config(&state.config.first_party_auth)?; + let secret = generate_totp_secret(); + let authenticator_id = Uuid::new_v4(); + let now = Utc::now(); + let expires_at = add_std_duration( + now, + state.config.first_party_auth.mfa_enrollment_ttl, + "MFA enrollment TTL", + )?; + let encrypted = cipher.encrypt(auth.account.id, authenticator_id, secret.as_slice())?; + state + .catalog + .begin_account_mfa_enrollment(AccountMfaEnrollmentRequest { + authenticator: AccountMfaAuthenticatorRecord { + id: authenticator_id, + account_id: auth.account.id, + state: AccountMfaAuthenticatorState::Pending, + secret: encrypted, + pending_expires_at: Some(expires_at), + last_used_timestep: None, + created_at: now, + activated_at: None, + disabled_at: None, + }, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::MfaEnrollmentStarted, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(auth.account.id), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "MFA enrollment"))?; + let encoded_secret = base32_no_pad(secret.as_slice()); + let otpauth_uri = build_otpauth_uri(&auth, &encoded_secret)?; + Ok(( + StatusCode::CREATED, + Json(BeginMfaEnrollmentResponse { + authenticator_id, + secret: encoded_secret, + otpauth_uri, + expires_at, + }), + ) + .into_response()) +} + +/// Verify and atomically activate one exact pending TOTP enrollment. +async fn activate_mfa( + State(state): State, + headers: HeaderMap, + Extension(auth): Extension, + Json(request): Json, +) -> Result { + require_browser_session(&auth)?; + require_trusted_browser_origin(&state, &headers)?; + require_fresh_mfa_for_replacement(&state, &auth).await?; + let authenticator_id = canonical_uuid(&request.authenticator_id)?; + let now = Utc::now(); + let authenticator = state + .catalog + .get_pending_account_mfa_authenticator(auth.account.id, authenticator_id, now) + .await + .map_err(map_mfa_proof_catalog_error)?; + let cipher = MfaSecretCipher::from_config(&state.config.first_party_auth)?; + let secret = cipher.decrypt(auth.account.id, authenticator.id, &authenticator.secret)?; + let verified_timestep = verify_totp(secret.as_slice(), &request.totp_code, now)?; + let (recovery_codes, recovery_code_seeds) = generate_recovery_codes(MFA_RECOVERY_CODE_COUNT); + state + .catalog + .activate_account_mfa(AccountMfaActivationRequest { + account_id: auth.account.id, + authenticator_id, + verified_timestep, + recovery_codes: recovery_code_seeds, + activated_at: now, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::MfaEnrollmentActivated, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(auth.account.id), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(map_mfa_proof_catalog_error)?; + Ok(( + StatusCode::OK, + Json(ActivateMfaResponse { + enabled: true, + recovery_codes, + }), + ) + .into_response()) +} + +/// Complete one password-bound second-factor challenge and issue a browser session. +async fn complete_mfa_challenge( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + require_trusted_browser_origin(&state, &headers)?; + if request.client_kind != AccountSessionClientKind::Browser { + return Err(AppError::BadRequest( + "MFA login completion is available only in the trusted browser portal".into(), + )); + } + let bound = decode_bound_token(&request.challenge_token)?; + if bound.client_kind != request.client_kind { + return rejected_mfa_challenge( + &state, + bound.account_id, + request.client_kind, + "mfa_challenge_client_mismatch", + ) + .await; + } + let authenticator = state + .catalog + .get_active_account_mfa_authenticator(bound.account_id) + .await + .map_err(map_mfa_proof_catalog_error)?; + let now = Utc::now(); + let proof = match (request.totp_code, request.recovery_code) { + (Some(totp_code), None) => { + let cipher = MfaSecretCipher::from_config(&state.config.first_party_auth)?; + let secret = + cipher.decrypt(bound.account_id, authenticator.id, &authenticator.secret)?; + AccountMfaChallengeProof::TotpTimestep(verify_totp(secret.as_slice(), &totp_code, now)?) + } + (None, Some(recovery_code)) => { + let recovery_code = Zeroizing::new(recovery_code); + AccountMfaChallengeProof::RecoveryCodeDigest(decode_recovery_code( + recovery_code.as_str(), + )?) + } + _ => { + return Err(AppError::BadRequest( + "exactly one MFA proof is required".into(), + )); + } + }; + let issued = issue_session( + &state.config.first_party_auth, + bound.account_id, + request.client_kind, + now, + Some(now), + )?; + let result = state + .catalog + .complete_account_mfa_challenge(AccountMfaChallengeCompletionRequest { + challenge_token_digest: bound.digest, + authenticator_id: authenticator.id, + proof, + issuance: issued.issuance.clone(), + completed_at: now, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::MfaChallengeCompleted, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(bound.account_id), + session_id: Some(issued.issuance.session.id), + client_kind: Some(request.client_kind), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "MFA challenge"))?; + match result { + AccountMfaChallengeCompletionResult::Completed(session) => { + let account = state + .catalog + .get_account(bound.account_id) + .await + .map_err(|error| AppError::from_catalog(error, "account"))?; + local_auth_response( + &state, + account, + session, + request.client_kind, + issued.access_token, + issued.refresh_token, + issued.issuance.refresh_expires_at, + ) + } + AccountMfaChallengeCompletionResult::Rejected => { + rejected_mfa_challenge( + &state, + bound.account_id, + request.client_kind, + "mfa_challenge_rejected", + ) + .await + } + } +} + +/// Disable active MFA after the router has established fresh MFA assurance. +async fn disable_mfa( + State(state): State, + headers: HeaderMap, + Extension(auth): Extension, +) -> Result { + require_browser_session(&auth)?; + require_trusted_browser_origin(&state, &headers)?; + let now = Utc::now(); + let disabled = state + .catalog + .disable_account_mfa(AccountMfaDisableRequest { + account_id: auth.account.id, + disabled_at: now, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::MfaDisabled, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(auth.account.id), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "MFA authenticator"))?; + if !disabled { + return Err(AppError::BadRequest("MFA is not active".into())); + } + Ok((StatusCode::OK, Json(DisableMfaResponse { enabled: false })).into_response()) +} + +/// Require a cookie-backed local session for browser credential management. +fn require_browser_session(auth: &AuthenticatedAccount) -> Result<(), AppError> { + if auth.via_cookie && auth.local_session_id.is_some() { + Ok(()) + } else { + Err(AppError::Forbidden( + "a trusted browser session is required".into(), + )) + } +} + +/// Require fresh assurance only when the account is replacing active MFA. +async fn require_fresh_mfa_for_replacement( + state: &AppState, + auth: &AuthenticatedAccount, +) -> Result<(), AppError> { + match state + .catalog + .get_active_account_mfa_authenticator(auth.account.id) + .await + { + Ok(_) => validate_fresh_authentication(state, auth), + Err(CatalogError::NotFound { .. }) => Ok(()), + Err(error) => Err(AppError::from_catalog(error, "MFA authenticator")), + } +} + +/// Build an HMAC-SHA256 otpauth URI without embedding any credential secret elsewhere. +fn build_otpauth_uri( + auth: &AuthenticatedAccount, + encoded_secret: &str, +) -> Result { + let label = auth + .account + .email + .as_deref() + .unwrap_or(auth.account.subject.as_str()); + let mut uri = Url::parse("otpauth://totp/") + .map_err(|_| AppError::Internal("TOTP enrollment URI construction failed".into()))?; + uri.path_segments_mut() + .map_err(|_| AppError::Internal("TOTP enrollment URI construction failed".into()))? + .push(&format!("FrameShift:{label}")); + uri.query_pairs_mut() + .append_pair("secret", encoded_secret) + .append_pair("issuer", "FrameShift") + .append_pair("algorithm", "SHA256") + .append_pair("digits", "6") + .append_pair("period", "30"); + Ok(uri.into()) +} + +/// Parse only the canonical lowercase-hyphenated UUID representation. +fn canonical_uuid(raw: &str) -> Result { + if raw.len() != 36 || raw.chars().any(char::is_whitespace) { + return Err(AppError::BadRequest("authenticator_id is invalid".into())); + } + let parsed = Uuid::parse_str(raw) + .map_err(|_| AppError::BadRequest("authenticator_id is invalid".into()))?; + if parsed.to_string() != raw { + return Err(AppError::BadRequest("authenticator_id is invalid".into())); + } + Ok(parsed) +} + +/// Convert pending/active lookup errors into one non-disclosing proof response. +fn map_mfa_proof_catalog_error(error: CatalogError) -> AppError { + match error { + CatalogError::NotFound { .. } | CatalogError::Unauthorized { .. } => { + AppError::Unauthorized("MFA proof is invalid or expired".into()) + } + other => AppError::from_catalog(other, "MFA authenticator"), + } +} + +/// Audit and return the one generic rejected challenge response. +async fn rejected_mfa_challenge( + state: &AppState, + account_id: Uuid, + client_kind: AccountSessionClientKind, + reason_code: &'static str, +) -> Result { + append_rejection_audit( + state, + Some(account_id), + None, + Some(client_kind), + None, + reason_code, + ) + .await; + Err(AppError::Unauthorized( + "MFA proof is invalid or expired".into(), + )) +} + +#[cfg(test)] +/// Unit tests for canonical MFA identifiers. +mod tests { + use super::*; + + /// UUID inputs must use the one lowercase hyphenated representation. + #[test] + fn authenticator_uuid_is_canonical() { + let id = Uuid::new_v4(); + assert_eq!(canonical_uuid(&id.to_string()).unwrap(), id); + assert!(canonical_uuid(&id.simple().to_string()).is_err()); + assert!(canonical_uuid(&id.to_string().to_uppercase()).is_err()); + } +} diff --git a/crates/frameshift-server/src/routes/mod.rs b/crates/frameshift-server/src/routes/mod.rs index 372a606..85e1df1 100644 --- a/crates/frameshift-server/src/routes/mod.rs +++ b/crates/frameshift-server/src/routes/mod.rs @@ -11,6 +11,8 @@ //! - [`memory`] -- `GET /v1/memory/health` read-only memory backend health. //! - [`invite_requests`] -- public invite-only account application intake. //! - [`local_auth`] -- invite redemption, password login, and session logout. +//! - [`mfa`] -- TOTP enrollment and password-bound challenge completion. +//! - [`native_auth`] -- loopback-only native authorization-code brokering. //! - [`invite_admin`] -- administrator review and one-time invitation issuance. //! - [`publication_intents`] -- authenticated creation and account-scoped retrieval. //! - [`publication_submissions`] -- signed quarantine admission and account-scoped retrieval. @@ -27,7 +29,9 @@ pub mod invite_admin; pub mod invite_requests; pub mod local_auth; pub mod memory; +pub mod mfa; pub mod moderation; +pub mod native_auth; pub mod ops; pub mod packs; pub mod publication_intents; diff --git a/crates/frameshift-server/src/routes/native_auth.rs b/crates/frameshift-server/src/routes/native_auth.rs new file mode 100644 index 0000000..2881ef7 --- /dev/null +++ b/crates/frameshift-server/src/routes/native_auth.rs @@ -0,0 +1,275 @@ +//! Browser-mediated native authorization-code broker with exact loopback and S256 binding. + +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Extension, Json, Router}; +use chrono::{DateTime, Utc}; +use frameshift_catalog::{ + AccountAuthAuditEventKind, AccountAuthAuditOutcome, AccountSessionClientKind, + NativeAuthorizationCodeCreationRequest, NativeAuthorizationCodeExchangeRequest, + NativeAuthorizationCodeExchangeResult, NativeAuthorizationCodeRecord, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::error::AppError; +use crate::first_party_auth::{ + add_std_duration, auth_audit_event, authorization_redirect, canonical_loopback_redirect, + canonical_oauth_state, decode_native_code, decode_pkce_challenge, generate_native_code, + issue_session, pkce_challenge_for_verifier, AuthAuditContext, +}; +use crate::middleware::account::AuthenticatedAccount; +use crate::routes::local_auth::{ + append_rejection_audit, local_auth_response, require_trusted_browser_origin, +}; +use crate::state::AppState; + +/// Tight body limit for authorization codes and PKCE material. +const MAX_NATIVE_AUTH_BODY_BYTES: usize = 8 * 1_024; + +/// Browser authorization request for a desktop or CLI callback. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeAuthorizeRequest { + /// Native client class receiving the one-time code. + pub client_kind: AccountSessionClientKind, + /// Exact IP-literal HTTP loopback callback URI. + pub redirect_uri: String, + /// Canonical base64url SHA-256 digest of the client verifier. + pub code_challenge: String, + /// Required anti-downgrade method identifier, exactly `S256`. + pub code_challenge_method: String, + /// Canonical bounded anti-CSRF correlation value reflected to the callback. + pub state: String, +} + +/// Browser navigation target returned after one authorization code is committed. +#[derive(Debug, Serialize)] +pub struct NativeAuthorizeResponse { + /// Exact loopback URI carrying only the one-time code and reflected state. + pub redirect_uri: String, + /// Exclusive authorization-code expiry. + pub expires_at: DateTime, +} + +/// Native authorization-code exchange input. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NativeTokenRequest { + /// Required grant identifier, exactly `authorization_code`. + pub grant_type: String, + /// Opaque one-time browser authorization code. + pub code: String, + /// RFC 7636 verifier whose SHA-256 digest must match the code binding. + pub code_verifier: String, + /// Exact callback URI used during browser authorization. + pub redirect_uri: String, + /// Native client class bound during browser authorization. + pub client_kind: AccountSessionClientKind, +} + +/// Build the unauthenticated native authorization-code exchange endpoint. +pub fn native_auth_public_router() -> Router { + Router::new() + .route("/native/token", post(exchange_native_token)) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + MAX_NATIVE_AUTH_BODY_BYTES, + )) +} + +/// Build the protected browser endpoint that issues native authorization codes. +pub fn native_auth_protected_router() -> Router { + Router::new() + .route("/native/authorize", post(authorize_native_client)) + .layer(tower_http::limit::RequestBodyLimitLayer::new( + MAX_NATIVE_AUTH_BODY_BYTES, + )) +} + +/// Issue one one-time native code from a fresh cookie-backed MFA session. +async fn authorize_native_client( + State(state): State, + headers: HeaderMap, + Extension(auth): Extension, + Json(request): Json, +) -> Result { + if !auth.via_cookie || auth.local_session_id.is_none() { + return Err(AppError::Forbidden( + "a trusted browser session is required".into(), + )); + } + require_trusted_browser_origin(&state, &headers)?; + if request.client_kind == AccountSessionClientKind::Browser { + return Err(AppError::BadRequest( + "native authorization requires a desktop or CLI client".into(), + )); + } + if request.code_challenge_method != "S256" { + return Err(AppError::BadRequest( + "code_challenge_method must be S256".into(), + )); + } + let redirect_uri = canonical_loopback_redirect(&request.redirect_uri)?; + let pkce_challenge = decode_pkce_challenge(&request.code_challenge)?; + let state_value = canonical_oauth_state(&request.state)?; + let mfa_verified_at = auth + .mfa_verified_at + .ok_or_else(|| AppError::Forbidden("fresh MFA verification required".into()))?; + let (code, code_digest, normalized_mfa_verified_at) = + generate_native_code(auth.account.id, request.client_kind, mfa_verified_at); + let now = Utc::now(); + let expires_at = add_std_duration( + now, + state.config.first_party_auth.native_code_ttl, + "native authorization-code TTL", + )?; + state + .catalog + .create_native_authorization_code(NativeAuthorizationCodeCreationRequest { + code: NativeAuthorizationCodeRecord { + id: Uuid::new_v4(), + account_id: auth.account.id, + token_digest: code_digest, + client_kind: request.client_kind, + redirect_uri: redirect_uri.clone(), + pkce_challenge, + mfa_verified_at: Some(normalized_mfa_verified_at), + created_at: now, + expires_at, + consumed_at: None, + }, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::NativeAuthorizationCodeCreated, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(auth.account.id), + client_kind: Some(request.client_kind), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "native authorization code"))?; + Ok(( + StatusCode::OK, + Json(NativeAuthorizeResponse { + redirect_uri: authorization_redirect(&redirect_uri, &code, &state_value)?, + expires_at, + }), + ) + .into_response()) +} + +/// Exchange one exact native code, callback URI, client class, and S256 verifier. +async fn exchange_native_token( + State(state): State, + Json(request): Json, +) -> Result { + if request.grant_type != "authorization_code" + || request.client_kind == AccountSessionClientKind::Browser + { + return Err(AppError::BadRequest( + "native authorization-code request is invalid".into(), + )); + } + let raw_code = Zeroizing::new(request.code); + let code = match decode_native_code(raw_code.as_str()) { + Ok(code) => code, + Err(_) => { + append_rejection_audit( + &state, + None, + None, + Some(request.client_kind), + None, + "native_code_invalid", + ) + .await; + return Err(invalid_native_code()); + } + }; + if code.client_kind != request.client_kind { + append_rejection_audit( + &state, + Some(code.account_id), + None, + Some(request.client_kind), + None, + "native_client_mismatch", + ) + .await; + return Err(invalid_native_code()); + } + let redirect_uri = canonical_loopback_redirect(&request.redirect_uri)?; + let pkce_challenge = pkce_challenge_for_verifier(&request.code_verifier)?; + let now = Utc::now(); + let issued = issue_session( + &state.config.first_party_auth, + code.account_id, + request.client_kind, + now, + Some(code.mfa_verified_at), + )?; + let result = state + .catalog + .exchange_native_authorization_code(NativeAuthorizationCodeExchangeRequest { + code_token_digest: code.digest, + client_kind: request.client_kind, + redirect_uri, + pkce_challenge, + issuance: issued.issuance.clone(), + exchanged_at: now, + audit_event: auth_audit_event( + AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed, + AccountAuthAuditOutcome::Success, + AuthAuditContext { + account_id: Some(code.account_id), + session_id: Some(issued.issuance.session.id), + client_kind: Some(request.client_kind), + ..AuthAuditContext::default() + }, + now, + ), + }) + .await + .map_err(|error| AppError::from_catalog(error, "native authorization code"))?; + match result { + NativeAuthorizationCodeExchangeResult::Exchanged(session) => { + let account = state + .catalog + .get_account(code.account_id) + .await + .map_err(|error| AppError::from_catalog(error, "account"))?; + local_auth_response( + &state, + account, + session, + request.client_kind, + issued.access_token, + issued.refresh_token, + issued.issuance.refresh_expires_at, + ) + } + NativeAuthorizationCodeExchangeResult::Rejected => { + append_rejection_audit( + &state, + Some(code.account_id), + None, + Some(request.client_kind), + None, + "native_code_rejected", + ) + .await; + Err(invalid_native_code()) + } + } +} + +/// Return the one response shared by unusable native code bindings. +fn invalid_native_code() -> AppError { + AppError::Unauthorized("authorization code is invalid or expired".into()) +} diff --git a/crates/frameshift-server/tests/account_routes.rs b/crates/frameshift-server/tests/account_routes.rs index 30d21f1..9d987f7 100644 --- a/crates/frameshift-server/tests/account_routes.rs +++ b/crates/frameshift-server/tests/account_routes.rs @@ -2,9 +2,9 @@ mod mocks; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use async_trait::async_trait; @@ -16,15 +16,23 @@ use base64::Engine as _; use chrono::Utc; use ed25519_dalek::{Signer as _, SigningKey}; use frameshift_catalog::{ - AccountInviteIntent, AccountInviteRecord, AccountInviteRequestRecord, AccountInviteStatus, - AccountStatus, Ed25519PublicKey, MembershipState, PlatformRole, PlatformRoleRecord, - PlatformRoleState, PublisherKeyRecord, PublisherKeyState, PublisherMembershipRecord, - PublisherModerationStatus, PublisherProfileRecord, PublisherRole, + AccountAuthAuditEventKind, AccountInviteIntent, AccountInviteRecord, + AccountInviteRequestRecord, AccountInviteStatus, AccountMfaAuthenticatorRecord, + AccountMfaAuthenticatorState, AccountStatus, Ed25519PublicKey, EncryptedTotpSecret, + MembershipState, PasswordRecoveryDeliveryKind, PasswordRecoveryDeliveryRecord, PlatformRole, + PlatformRoleRecord, PlatformRoleState, PublisherKeyRecord, PublisherKeyState, + PublisherMembershipRecord, PublisherModerationStatus, PublisherProfileRecord, PublisherRole, }; use frameshift_server::account_auth::{BearerTokenVerifier, OidcAuthError, VerifiedOidcIdentity}; use frameshift_server::metrics::Metrics; +use frameshift_server::recovery_delivery::{ + parse_recovery_delivery_payload, run_recovery_delivery_worker, RecoveryDeliveryCipher, + RecoveryDeliveryDispatcher, RecoveryDeliveryPayload, RecoveryDeliveryWorkerConfig, + RecoveryDispatchError, RecoveryDispatchReceipt, +}; use frameshift_server::{ - app, AppState, FirstPartyAuthConfig, InviteRequestConfig, LogFormat, OidcConfig, ServerConfig, + app, AppState, FirstPartyAuthConfig, InviteRequestConfig, LogFormat, OidcConfig, + PasswordRecoveryConfig, ServerConfig, }; use http_body_util::BodyExt as _; use secrecy::SecretString; @@ -89,6 +97,69 @@ impl BearerTokenVerifier for FakeVerifier { } } +/// Deterministic provider outcome consumed by the recovery worker test double. +#[derive(Clone, Copy)] +enum FakeRecoveryOutcome { + /// Return one successful provider acknowledgement. + Success, + /// Return one retryable transport classification. + Retryable, + /// Return one terminal provider rejection. + Permanent, +} + +/// Scripted recovery dispatcher that records only non-secret delivery metadata. +#[derive(Clone)] +struct FakeRecoveryDispatcher { + /// Ordered outcomes returned by subsequent provider calls. + outcomes: Arc>>, + /// Outbox identifiers observed at the provider boundary. + observed_ids: Arc>>, +} + +/// Constructors for the scripted recovery dispatcher. +impl FakeRecoveryDispatcher { + /// Build a dispatcher with an ordered outcome script. + fn new(outcomes: impl IntoIterator) -> Self { + Self { + outcomes: Arc::new(Mutex::new(outcomes.into_iter().collect())), + observed_ids: Arc::new(Mutex::new(Vec::new())), + } + } +} + +/// Scripted implementation of the recovery provider boundary. +#[async_trait] +impl RecoveryDeliveryDispatcher for FakeRecoveryDispatcher { + /// Record the stable idempotency identifier and return the next scripted outcome. + async fn deliver( + &self, + outbox_id: Uuid, + _recipient: &str, + _payload: RecoveryDeliveryPayload<'_>, + ) -> Result { + self.observed_ids.lock().unwrap().push(outbox_id); + match self + .outcomes + .lock() + .unwrap() + .pop_front() + .unwrap_or(FakeRecoveryOutcome::Success) + { + FakeRecoveryOutcome::Success => Ok(RecoveryDispatchReceipt { + provider_message_id: format!("provider-{outbox_id}"), + }), + FakeRecoveryOutcome::Retryable => Err(RecoveryDispatchError::Retryable { + reason: "test_retry", + retry_after: None, + }), + FakeRecoveryOutcome::Permanent => Err(RecoveryDispatchError::Permanent { + reason: "test_permanent", + }), + } + } +} + /// Build a server configuration with account authentication enabled. fn test_config() -> Arc { test_config_with_invites(InviteRequestConfig::disabled()) @@ -165,11 +236,29 @@ fn first_party_test_config() -> Arc { config.cors_allowed_origins = "https://frameshift.test".to_string(); config.first_party_auth = FirstPartyAuthConfig { password_pepper: SecretString::new("integration-test-password-pepper".to_string()), + mfa_encryption_key: SecretString::new(URL_SAFE_NO_PAD.encode([23_u8; 32])), + native_authorization_url: "https://frameshift.test/account/".to_string(), ..FirstPartyAuthConfig::disabled() }; Arc::new(config) } +/// Build deterministic enabled password-recovery configuration for route tests. +fn password_recovery_test_config() -> Arc { + let mut config = (*first_party_test_config()).clone(); + config.first_party_auth.recovery = PasswordRecoveryConfig { + enabled: true, + provider_api_key: SecretString::new("re_test_provider_key".to_string()), + from_address: "FrameShift ".to_string(), + reset_url: "https://frameshift.test/recover/".to_string(), + delivery_key: SecretString::new(URL_SAFE_NO_PAD.encode([29_u8; 32])), + key_version: 7, + token_ttl: Duration::from_secs(60 * 60), + request_cooldown: Duration::from_secs(15 * 60), + }; + Arc::new(config) +} + /// Build application state around shared catalog and bearer verifier doubles. fn test_state(catalog: MockCatalog, verifier: Option) -> AppState { test_state_with_config(catalog, verifier, test_config()) @@ -273,12 +362,62 @@ async fn send_browser( .unwrap() } +/// Retry a valid browser password operation for at most thirty seconds of Argon contention. +async fn send_browser_password_operation_when_capacity_is_available( + state: AppState, + path: &str, + body: Value, +) -> axum::http::Response { + let mut last_response = None; + for _ in 0..600 { + let response = send_browser( + state.clone(), + Method::POST, + path, + Some("https://frameshift.test"), + None, + Some(body.clone()), + ) + .await; + if response.status() != StatusCode::SERVICE_UNAVAILABLE { + return response; + } + last_response = Some(response); + tokio::time::sleep(Duration::from_millis(50)).await; + } + last_response.expect("the bounded capacity retry loop always sends at least one request") +} + +/// Retry one browser password login under the shared Argon2 work bound. +async fn send_password_login_when_capacity_is_available( + state: AppState, + body: Value, +) -> axum::http::Response { + send_browser_password_operation_when_capacity_is_available(state, "/v1/auth/login", body).await +} + /// Decode one JSON response body after its status has been asserted. async fn response_json(response: axum::http::Response) -> Value { let bytes = response.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() } +/// Extract one named cookie pair from a response carrying multiple Set-Cookie headers. +fn response_cookie(response: &axum::http::Response, name: &str) -> String { + let prefix = format!("{name}="); + response + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with(&prefix)) + .expect("the response carries the requested cookie") + .split(';') + .next() + .unwrap() + .to_string() +} + /// Provision one test account and return its generated stable identifier. async fn provision_account(state: AppState, token: &str) -> Uuid { let response = send(state, Method::GET, "/v1/account", Some(token), None).await; @@ -362,6 +501,156 @@ fn fixture_bootstrap_invite(catalog: &MockCatalog, email: &str) -> String { token } +/// Decrypt the single mock reset delivery and return its caller-held token. +fn fixture_recovery_token(catalog: &MockCatalog, config: &ServerConfig) -> String { + let delivery = catalog + .state + .read() + .unwrap() + .password_recovery_deliveries + .values() + .find(|delivery| delivery.kind == frameshift_catalog::PasswordRecoveryDeliveryKind::Reset) + .cloned() + .expect("one reset delivery"); + let cipher = RecoveryDeliveryCipher::from_config(config) + .unwrap() + .expect("enabled recovery cipher"); + let plaintext = cipher + .decrypt( + delivery.id, + delivery.kind, + delivery.key_version, + &delivery.nonce, + &delivery.ciphertext, + ) + .unwrap(); + match parse_recovery_delivery_payload(&plaintext).unwrap() { + RecoveryDeliveryPayload::Reset { token, .. } => token.to_string(), + RecoveryDeliveryPayload::PasswordChanged => panic!("expected reset payload"), + } +} + +/// Wait until the detached recovery request has durably populated the mock catalog. +async fn wait_for_recovery_enqueue(catalog: &MockCatalog) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if !catalog + .state + .read() + .unwrap() + .password_recovery_deliveries + .is_empty() + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("recovery enqueue deadline"); +} + +/// Seed one encrypted pending reset delivery for focused worker tests. +fn fixture_worker_delivery(catalog: &MockCatalog, config: &ServerConfig, tampered: bool) -> Uuid { + let cipher = RecoveryDeliveryCipher::from_config(config) + .unwrap() + .expect("enabled recovery cipher"); + let id = Uuid::new_v4(); + let now = Utc::now(); + let mut encrypted = cipher + .encrypt_reset( + id, + &config.first_party_auth.recovery.reset_url, + &URL_SAFE_NO_PAD.encode([43_u8; 32]), + ) + .unwrap(); + if tampered { + encrypted.ciphertext[0] ^= 0x01; + } + catalog + .state + .write() + .unwrap() + .password_recovery_deliveries + .insert( + id, + PasswordRecoveryDeliveryRecord { + id, + account_id: Uuid::new_v4(), + kind: PasswordRecoveryDeliveryKind::Reset, + recipient: "worker@example.test".to_string(), + ciphertext: encrypted.ciphertext, + nonce: encrypted.nonce, + key_version: encrypted.key_version, + attempt_count: 0, + last_attempt_at: None, + claim_id: None, + claimed_at: None, + next_attempt_at: now, + expires_at: now + chrono::Duration::hours(1), + sent_at: None, + provider_message_id: None, + failed_at: None, + last_error_code: None, + created_at: now, + }, + ); + id +} + +/// Run one focused worker until the selected row reaches a terminal settlement. +async fn run_worker_fixture( + catalog: MockCatalog, + config: &ServerConfig, + dispatcher: FakeRecoveryDispatcher, + delivery_id: Uuid, +) -> PasswordRecoveryDeliveryRecord { + let cipher = RecoveryDeliveryCipher::from_config(config) + .unwrap() + .expect("enabled recovery cipher"); + let catalog_backend: Arc = Arc::new(catalog.clone()); + let provider: Arc = Arc::new(dispatcher); + let (stop_sender, stop_receiver) = tokio::sync::watch::channel(false); + let worker = tokio::spawn(run_recovery_delivery_worker( + catalog_backend, + provider, + cipher, + RecoveryDeliveryWorkerConfig { + poll_interval: Duration::from_millis(2), + claim_ttl: Duration::from_secs(1), + batch_size: 1, + retry_initial: Duration::from_millis(2), + retry_max: Duration::from_millis(8), + max_attempts: 3, + }, + stop_receiver, + )); + let settled = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(delivery) = catalog + .state + .read() + .unwrap() + .password_recovery_deliveries + .get(&delivery_id) + .filter(|delivery| delivery.sent_at.is_some() || delivery.failed_at.is_some()) + .cloned() + { + return delivery; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .expect("worker settlement deadline"); + let _ = stop_sender.send(true); + tokio::time::timeout(Duration::from_secs(1), worker) + .await + .expect("worker stop deadline") + .expect("worker task"); + settled +} + /// Seed one pending invite application for administrator route tests. fn fixture_invite_application(catalog: &MockCatalog, email: &str) -> Uuid { let now = Utc::now(); @@ -585,13 +874,24 @@ async fn invite_redemption_creates_one_browser_account_and_logout_revokes_it() { .await; assert_eq!(untrusted.status(), StatusCode::FORBIDDEN); - let registered = send_browser( + let mut blocklisted_registration = registration.clone(); + blocklisted_registration["password"] = json!(" FrameShiftPassword "); + let blocklisted = send_browser( state.clone(), Method::POST, "/v1/auth/register", Some("https://frameshift.test"), None, - Some(registration.clone()), + Some(blocklisted_registration), + ) + .await; + assert_eq!(blocklisted.status(), StatusCode::BAD_REQUEST); + assert!(catalog.state.read().unwrap().accounts.is_empty()); + + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + registration.clone(), ) .await; assert_eq!(registered.status(), StatusCode::OK); @@ -649,6 +949,17 @@ async fn invite_redemption_creates_one_browser_account_and_logout_revokes_it() { .await; assert_eq!(authenticated.status(), StatusCode::OK); + let browser_access_token = cookie.split_once('=').unwrap().1; + let wrong_transport = send( + state.clone(), + Method::GET, + "/v1/account", + Some(browser_access_token), + None, + ) + .await; + assert_eq!(wrong_transport.status(), StatusCode::UNAUTHORIZED); + let logged_out = send_browser( state.clone(), Method::POST, @@ -678,66 +989,716 @@ async fn invite_redemption_creates_one_browser_account_and_logout_revokes_it() { assert_eq!(revoked.status(), StatusCode::UNAUTHORIZED); } -/// Desktop login returns an explicit bearer token and rejects the wrong password. +/// Replaying a consumed browser refresh token revokes the full rotated session family. #[tokio::test] -async fn desktop_password_login_uses_explicit_revocable_bearer_sessions() { +async fn browser_refresh_rotation_detects_replay_and_revokes_the_family() { let catalog = MockCatalog::new(); - let invite_token = fixture_bootstrap_invite(&catalog, "desktop@example.test"); - let state = test_state_with_config(catalog, None, first_party_test_config()); - let registered = send( + let invite_token = fixture_bootstrap_invite(&catalog, "refresh-owner@example.test"); + let state = test_state_with_config(catalog.clone(), None, first_party_test_config()); + let registered = send_browser_password_operation_when_capacity_is_available( state.clone(), - Method::POST, "/v1/auth/register", + json!({ + "invite_token": invite_token, + "email": "refresh-owner@example.test", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(registered.status(), StatusCode::OK); + let original_access_cookie = response_cookie(®istered, "__Host-frameshift_session"); + let original_refresh_cookie = response_cookie(®istered, "__Host-frameshift_refresh"); + + let refreshed = send_browser( + state.clone(), + Method::POST, + "/v1/auth/refresh", + Some("https://frameshift.test"), + Some(&original_refresh_cookie), + Some(json!({"client_kind": "browser"})), + ) + .await; + assert_eq!(refreshed.status(), StatusCode::OK); + let replacement_access_cookie = response_cookie(&refreshed, "__Host-frameshift_session"); + let replacement_refresh_cookie = response_cookie(&refreshed, "__Host-frameshift_refresh"); + assert_ne!(replacement_access_cookie, original_access_cookie); + assert_ne!(replacement_refresh_cookie, original_refresh_cookie); + let refreshed_body = response_json(refreshed).await; + assert!(refreshed_body["access_token"].is_null()); + assert!(refreshed_body["refresh_token"].is_null()); + + let superseded_access = send_browser( + state.clone(), + Method::GET, + "/v1/account", None, - Some(json!({ + Some(&original_access_cookie), + None, + ) + .await; + assert_eq!(superseded_access.status(), StatusCode::UNAUTHORIZED); + let replacement_access = send_browser( + state.clone(), + Method::GET, + "/v1/account", + None, + Some(&replacement_access_cookie), + None, + ) + .await; + assert_eq!(replacement_access.status(), StatusCode::OK); + + let replayed = send_browser( + state.clone(), + Method::POST, + "/v1/auth/refresh", + Some("https://frameshift.test"), + Some(&original_refresh_cookie), + Some(json!({"client_kind": "browser"})), + ) + .await; + assert_eq!(replayed.status(), StatusCode::UNAUTHORIZED); + let revoked_replacement = send_browser( + state, + Method::GET, + "/v1/account", + None, + Some(&replacement_access_cookie), + None, + ) + .await; + assert_eq!(revoked_replacement.status(), StatusCode::UNAUTHORIZED); + + let stored = catalog.state.read().unwrap(); + assert!(stored + .account_sessions + .values() + .all(|session| session.revoked_at.is_some())); + assert_eq!( + stored + .account_auth_audit_events + .iter() + .filter(|event| event.event_kind == AccountAuthAuditEventKind::SessionRefreshed) + .count(), + 1 + ); + assert_eq!( + stored + .account_auth_audit_events + .iter() + .filter(|event| event.event_kind == AccountAuthAuditEventKind::SessionReplayRevoked) + .count(), + 1 + ); +} + +/// A native code preserves state, enforces S256, and succeeds only once. +#[tokio::test] +async fn native_authorization_code_is_exactly_bound_and_single_use() { + let catalog = MockCatalog::new(); + let invite_token = fixture_bootstrap_invite(&catalog, "native-owner@example.test"); + let state = test_state_with_config(catalog.clone(), None, first_party_test_config()); + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + json!({ "invite_token": invite_token, - "email": "desktop@example.test", + "email": "native-owner@example.test", "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(registered.status(), StatusCode::OK); + let browser_cookie = response_cookie(®istered, "__Host-frameshift_session"); + { + let mut stored = catalog.state.write().unwrap(); + stored + .account_sessions + .values_mut() + .next() + .unwrap() + .mfa_verified_at = Some(Utc::now()); + } + + let verifier = "a".repeat(43); + let wrong_verifier = "b".repeat(43); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + let state_value = URL_SAFE_NO_PAD.encode([37_u8; 32]); + let callback = "http://127.0.0.1:45678/callback"; + let authorized = send_browser( + state.clone(), + Method::POST, + "/v1/auth/native/authorize", + Some("https://frameshift.test"), + Some(&browser_cookie), + Some(json!({ + "client_kind": "desktop", + "redirect_uri": callback, + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state_value + })), + ) + .await; + assert_eq!(authorized.status(), StatusCode::OK); + let redirect = url::Url::parse( + response_json(authorized).await["redirect_uri"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(redirect.scheme(), "http"); + assert_eq!(redirect.host_str(), Some("127.0.0.1")); + assert_eq!(redirect.port(), Some(45678)); + assert_eq!(redirect.path(), "/callback"); + let redirect_query: HashMap<_, _> = redirect.query_pairs().into_owned().collect(); + assert_eq!(redirect_query.get("state"), Some(&state_value)); + let code = redirect_query.get("code").unwrap().clone(); + + let wrong_exchange = send( + state.clone(), + Method::POST, + "/v1/auth/native/token", + None, + Some(json!({ + "grant_type": "authorization_code", + "code": code.clone(), + "code_verifier": wrong_verifier, + "redirect_uri": callback, "client_kind": "desktop" })), ) .await; + assert_eq!(wrong_exchange.status(), StatusCode::UNAUTHORIZED); + + let exchange_body = json!({ + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "redirect_uri": callback, + "client_kind": "desktop" + }); + let exchanged = send( + state.clone(), + Method::POST, + "/v1/auth/native/token", + None, + Some(exchange_body.clone()), + ) + .await; + assert_eq!(exchanged.status(), StatusCode::OK); + let exchanged_body = response_json(exchanged).await; + let access_token = exchanged_body["access_token"].as_str().unwrap(); + assert!(exchanged_body["refresh_token"].as_str().is_some()); + assert_eq!(exchanged_body["token_type"], "Bearer"); + let authenticated = send( + state.clone(), + Method::GET, + "/v1/account", + Some(access_token), + None, + ) + .await; + assert_eq!(authenticated.status(), StatusCode::OK); + + let native_cookie = format!("__Host-frameshift_session={access_token}"); + let wrong_transport = send_browser( + state.clone(), + Method::GET, + "/v1/account", + None, + Some(&native_cookie), + None, + ) + .await; + assert_eq!(wrong_transport.status(), StatusCode::UNAUTHORIZED); + + let replayed = send( + state, + Method::POST, + "/v1/auth/native/token", + None, + Some(exchange_body), + ) + .await; + assert_eq!(replayed.status(), StatusCode::UNAUTHORIZED); + + let stored = catalog.state.read().unwrap(); + assert!(stored + .native_authorization_codes + .values() + .all(|stored_code| stored_code.consumed_at.is_some())); + assert_eq!( + stored + .account_auth_audit_events + .iter() + .filter(|event| { + event.event_kind == AccountAuthAuditEventKind::NativeAuthorizationCodeCreated + }) + .count(), + 1 + ); + assert_eq!( + stored + .account_auth_audit_events + .iter() + .filter(|event| { + event.event_kind == AccountAuthAuditEventKind::NativeAuthorizationCodeConsumed + }) + .count(), + 1 + ); +} + +/// Initial MFA enrollment remains available while stale sessions cannot replace active MFA. +#[tokio::test] +async fn stale_browser_session_cannot_start_or_activate_mfa_replacement() { + let catalog = MockCatalog::new(); + let invite_token = fixture_bootstrap_invite(&catalog, "mfa-owner@example.test"); + let state = test_state_with_config(catalog.clone(), None, first_party_test_config()); + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + json!({ + "invite_token": invite_token.clone(), + "email": "mfa-owner@example.test", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; assert_eq!(registered.status(), StatusCode::OK); - let initial_token = response_json(registered).await["token"] + let cookie = registered + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with("__Host-frameshift_session=")) + .expect("browser login returns the access-token cookie") + .split(';') + .next() + .unwrap() + .to_string(); + + let initial = send_browser( + state.clone(), + Method::POST, + "/v1/auth/mfa/enroll", + Some("https://frameshift.test"), + Some(&cookie), + None, + ) + .await; + assert_eq!(initial.status(), StatusCode::CREATED); + let initial_authenticator_id = response_json(initial).await["authenticator_id"] .as_str() .unwrap() .to_string(); - let wrong_password = send( + let active_authenticator_id = Uuid::new_v4(); + { + let now = Utc::now(); + let mut stored = catalog.state.write().unwrap(); + let account_id = *stored.accounts.keys().next().unwrap(); + stored + .account_sessions + .values_mut() + .next() + .unwrap() + .mfa_verified_at = Some(now - chrono::Duration::minutes(10)); + stored.account_mfa_authenticators.insert( + active_authenticator_id, + AccountMfaAuthenticatorRecord { + id: active_authenticator_id, + account_id, + state: AccountMfaAuthenticatorState::Active, + secret: EncryptedTotpSecret { + ciphertext: vec![7_u8; 48], + nonce: [11_u8; 24], + key_version: 1, + }, + pending_expires_at: None, + last_used_timestep: Some(1), + created_at: now - chrono::Duration::days(1), + activated_at: Some(now - chrono::Duration::days(1)), + disabled_at: None, + }, + ); + } + + let replacement = send_browser( state.clone(), Method::POST, - "/v1/auth/login", + "/v1/auth/mfa/enroll", + Some("https://frameshift.test"), + Some(&cookie), None, + ) + .await; + assert_eq!(replacement.status(), StatusCode::FORBIDDEN); + let activation = send_browser( + state, + Method::POST, + "/v1/auth/mfa/activate", + Some("https://frameshift.test"), + Some(&cookie), Some(json!({ - "email": "desktop@example.test", - "password": "incorrect horse battery staple", - "client_kind": "desktop" + "authenticator_id": initial_authenticator_id, + "totp_code": "000000" })), ) .await; - assert_eq!(wrong_password.status(), StatusCode::UNAUTHORIZED); + assert_eq!(activation.status(), StatusCode::FORBIDDEN); + + let stored = catalog.state.read().unwrap(); + assert_eq!( + stored + .account_mfa_authenticators + .values() + .filter(|authenticator| { + authenticator.state == AccountMfaAuthenticatorState::Pending + }) + .count(), + 1 + ); + assert_eq!( + stored + .account_mfa_authenticators + .get(&active_authenticator_id) + .unwrap() + .state, + AccountMfaAuthenticatorState::Active + ); +} + +/// Recovery stays unavailable without complete configuration and always requires exact Origin. +#[tokio::test] +async fn password_recovery_fails_closed_before_catalog_access() { + let catalog = MockCatalog::new(); + let disabled_state = test_state_with_config(catalog.clone(), None, first_party_test_config()); + let disabled = send_browser( + disabled_state, + Method::POST, + "/v1/auth/password-recovery/request", + Some("https://frameshift.test"), + None, + Some(json!({"email": "member@example.test"})), + ) + .await; + assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); + + let enabled_state = + test_state_with_config(catalog.clone(), None, password_recovery_test_config()); + let missing_origin = send_browser( + enabled_state, + Method::POST, + "/v1/auth/password-recovery/request", + None, + None, + Some(json!({"email": "member@example.test"})), + ) + .await; + assert_eq!(missing_origin.status(), StatusCode::FORBIDDEN); + let stored = catalog.state.read().unwrap(); + assert!(stored.password_recovery_tokens.is_empty()); + assert!(stored.password_recovery_deliveries.is_empty()); +} + +/// Known and unknown recovery requests return the same acknowledgement without plaintext storage. +#[tokio::test] +async fn password_recovery_request_is_indistinguishable_and_encrypted() { + let catalog = MockCatalog::new(); + catalog.delay_password_recovery_enqueue("recover@example.test", Duration::from_secs(1)); + let invite_token = fixture_bootstrap_invite(&catalog, "recover@example.test"); + let config = password_recovery_test_config(); + let state = test_state_with_config(catalog.clone(), None, Arc::clone(&config)); + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + json!({ + "invite_token": invite_token, + "email": "recover@example.test", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(registered.status(), StatusCode::OK); + + let known_started_at = tokio::time::Instant::now(); + let known = send_browser( + state.clone(), + Method::POST, + "/v1/auth/password-recovery/request", + Some("https://frameshift.test"), + None, + Some(json!({"email": " Recover@Example.Test "})), + ) + .await; + let known_duration = known_started_at.elapsed(); + assert_eq!(known.status(), StatusCode::ACCEPTED); + assert!(known_duration >= Duration::from_millis(200)); + assert!(known_duration < Duration::from_millis(750)); + let known_body = response_json(known).await; + let unknown = send_browser( + state, + Method::POST, + "/v1/auth/password-recovery/request", + Some("https://frameshift.test"), + None, + Some(json!({"email": "unknown@example.test"})), + ) + .await; + assert_eq!(unknown.status(), StatusCode::ACCEPTED); + assert_eq!(response_json(unknown).await, known_body); + + wait_for_recovery_enqueue(&catalog).await; + let token = fixture_recovery_token(&catalog, &config); + let decoded_token = URL_SAFE_NO_PAD.decode(&token).unwrap(); + let stored = catalog.state.read().unwrap(); + assert_eq!(stored.password_recovery_tokens.len(), 1); + assert_eq!(stored.password_recovery_deliveries.len(), 1); + let token_record = stored.password_recovery_tokens.values().next().unwrap(); + assert_eq!( + token_record.token_digest, + Sha256::digest(decoded_token).to_vec() + ); + let delivery = stored.password_recovery_deliveries.values().next().unwrap(); + assert!(!delivery + .ciphertext + .windows(token.len()) + .any(|window| window == token.as_bytes())); + assert!(!delivery + .ciphertext + .windows(config.first_party_auth.recovery.reset_url.len()) + .any(|window| window == config.first_party_auth.recovery.reset_url.as_bytes())); +} + +/// A valid reset token changes the password, revokes sessions, and is single use. +#[tokio::test] +async fn password_recovery_completion_is_atomic_and_generic_on_replay() { + let catalog = MockCatalog::new(); + let invite_token = fixture_bootstrap_invite(&catalog, "complete@example.test"); + let config = password_recovery_test_config(); + let state = test_state_with_config(catalog.clone(), None, Arc::clone(&config)); + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + json!({ + "invite_token": invite_token, + "email": "complete@example.test", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(registered.status(), StatusCode::OK); + let original_hash = catalog.state.read().unwrap().account_password_credentials + ["complete@example.test"] + .password_hash + .clone(); - let logged_in = send( + let requested = send_browser( state.clone(), Method::POST, - "/v1/auth/login", + "/v1/auth/password-recovery/request", + Some("https://frameshift.test"), + None, + Some(json!({"email": "complete@example.test"})), + ) + .await; + assert_eq!(requested.status(), StatusCode::ACCEPTED); + wait_for_recovery_enqueue(&catalog).await; + let token = fixture_recovery_token(&catalog, &config); + let replacement = "violet moons remember every careful promise"; + let completed = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/password-recovery/complete", + json!({"token": token.clone(), "password": replacement}), + ) + .await; + assert_eq!(completed.status(), StatusCode::NO_CONTENT); + + { + let stored = catalog.state.read().unwrap(); + let credential = &stored.account_password_credentials["complete@example.test"]; + assert_ne!(credential.password_hash, original_hash); + assert!(stored + .account_sessions + .values() + .all(|session| session.revoked_at.is_some())); + assert!(stored + .password_recovery_tokens + .values() + .all(|token| token.consumed_at.is_some() || token.revoked_at.is_some())); + assert_eq!(stored.password_recovery_deliveries.len(), 2); + let changed = stored + .password_recovery_deliveries + .values() + .find(|delivery| { + delivery.kind == frameshift_catalog::PasswordRecoveryDeliveryKind::PasswordChanged + }) + .unwrap(); + let cipher = RecoveryDeliveryCipher::from_config(&config) + .unwrap() + .expect("enabled recovery cipher"); + let plaintext = cipher + .decrypt( + changed.id, + changed.kind, + changed.key_version, + &changed.nonce, + &changed.ciphertext, + ) + .unwrap(); + assert!(matches!( + parse_recovery_delivery_payload(&plaintext).unwrap(), + RecoveryDeliveryPayload::PasswordChanged + )); + } + + let replay = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/password-recovery/complete", + json!({"token": token, "password": replacement}), + ) + .await; + assert_eq!(replay.status(), StatusCode::BAD_REQUEST); + let replay_body = response_json(replay).await; + let invalid = send_browser_password_operation_when_capacity_is_available( + state, + "/v1/auth/password-recovery/complete", + json!({ + "token": URL_SAFE_NO_PAD.encode([99_u8; 32]), + "password": replacement + }), + ) + .await; + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!(response_json(invalid).await, replay_body); +} + +/// The worker retries transient failures with the same provider idempotency identifier. +#[tokio::test] +async fn password_recovery_worker_retries_then_acknowledges() { + let catalog = MockCatalog::new(); + let config = password_recovery_test_config(); + let delivery_id = fixture_worker_delivery(&catalog, &config, false); + let dispatcher = + FakeRecoveryDispatcher::new([FakeRecoveryOutcome::Retryable, FakeRecoveryOutcome::Success]); + let observed_ids = Arc::clone(&dispatcher.observed_ids); + let settled = run_worker_fixture(catalog, &config, dispatcher, delivery_id).await; + + assert!(settled.sent_at.is_some()); + assert!(settled.failed_at.is_none()); + assert_eq!(settled.attempt_count, 2); + assert_eq!( + *observed_ids.lock().unwrap(), + vec![delivery_id, delivery_id] + ); +} + +/// The worker terminally settles provider rejection and authenticated-data tampering. +#[tokio::test] +async fn password_recovery_worker_fails_permanent_and_tampered_deliveries() { + let config = password_recovery_test_config(); + + let rejected_catalog = MockCatalog::new(); + let rejected_id = fixture_worker_delivery(&rejected_catalog, &config, false); + let rejected_dispatcher = FakeRecoveryDispatcher::new([FakeRecoveryOutcome::Permanent]); + let rejected = + run_worker_fixture(rejected_catalog, &config, rejected_dispatcher, rejected_id).await; + assert!(rejected.sent_at.is_none()); + assert!(rejected.failed_at.is_some()); + assert_eq!(rejected.last_error_code.as_deref(), Some("test_permanent")); + + let tampered_catalog = MockCatalog::new(); + let tampered_id = fixture_worker_delivery(&tampered_catalog, &config, true); + let tampered_dispatcher = FakeRecoveryDispatcher::new([]); + let observed_ids = Arc::clone(&tampered_dispatcher.observed_ids); + let tampered = + run_worker_fixture(tampered_catalog, &config, tampered_dispatcher, tampered_id).await; + assert!(tampered.sent_at.is_none()); + assert!(tampered.failed_at.is_some()); + assert_eq!( + tampered.last_error_code.as_deref(), + Some("authentication_failed") + ); + assert!(observed_ids.lock().unwrap().is_empty()); +} + +/// Password credentials stay in the trusted browser portal instead of native clients. +#[tokio::test] +async fn password_login_is_confined_to_the_trusted_browser_portal() { + let catalog = MockCatalog::new(); + let invite_token = fixture_bootstrap_invite(&catalog, "desktop@example.test"); + let state = test_state_with_config(catalog, None, first_party_test_config()); + let native_registration = send( + state.clone(), + Method::POST, + "/v1/auth/register", None, Some(json!({ - "email": " DESKTOP@example.test ", + "invite_token": invite_token.clone(), + "email": "desktop@example.test", "password": "correct horse battery staple", "client_kind": "desktop" })), ) .await; + assert_eq!(native_registration.status(), StatusCode::BAD_REQUEST); + + let registered = send_browser_password_operation_when_capacity_is_available( + state.clone(), + "/v1/auth/register", + json!({ + "invite_token": invite_token, + "email": "desktop@example.test", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(registered.status(), StatusCode::OK); + assert!(response_json(registered).await["access_token"].is_null()); + + let wrong_password = send_password_login_when_capacity_is_available( + state.clone(), + json!({ + "email": "desktop@example.test", + "password": "wrong", + "client_kind": "browser" + }), + ) + .await; + assert_eq!(wrong_password.status(), StatusCode::UNAUTHORIZED); + + let logged_in = send_password_login_when_capacity_is_available( + state.clone(), + json!({ + "email": " DESKTOP@example.test ", + "password": "correct horse battery staple", + "client_kind": "browser" + }), + ) + .await; assert_eq!(logged_in.status(), StatusCode::OK); - let token = response_json(logged_in).await["token"] - .as_str() + let cookie = logged_in + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with("__Host-frameshift_session=")) + .expect("browser login returns the access-token cookie") + .split(';') + .next() .unwrap() .to_string(); - assert_ne!(token, initial_token); - assert_eq!(URL_SAFE_NO_PAD.decode(&token).unwrap().len(), 32); - let authenticated = send(state, Method::GET, "/v1/account", Some(&token), None).await; + let authenticated = + send_browser(state, Method::GET, "/v1/account", None, Some(&cookie), None).await; assert_eq!(authenticated.status(), StatusCode::OK); } diff --git a/crates/frameshift-server/tests/local_auth_pepper_rotation.rs b/crates/frameshift-server/tests/local_auth_pepper_rotation.rs index 78cdddc..d474292 100644 --- a/crates/frameshift-server/tests/local_auth_pepper_rotation.rs +++ b/crates/frameshift-server/tests/local_auth_pepper_rotation.rs @@ -37,11 +37,14 @@ fn test_config(pepper: &str, pepper_version: i16, previous: Vec<(i16, &str)>) -> let mut config = ServerConfig::from_env().unwrap(); config.bind_addr = "127.0.0.1:0".parse().unwrap(); config.log_level = "off".to_string(); + config.cors_allowed_origins = "https://frameshift.test".to_string(); config.abuse_rate_per_min = 0; config.download_rate_per_min = 0; config.first_party_auth = FirstPartyAuthConfig { password_pepper: SecretString::new(pepper.to_string()), pepper_version, + mfa_encryption_key: SecretString::new(URL_SAFE_NO_PAD.encode([23_u8; 32])), + native_authorization_url: "https://frameshift.test/account/".to_string(), previous_peppers: previous .into_iter() .map(|(version, secret)| (version, SecretString::new(secret.to_string()))) @@ -99,6 +102,7 @@ async fn send( body: Option, ) -> axum::http::Response { let mut builder = Request::builder().method(method).uri(path); + builder = builder.header("origin", "https://frameshift.test"); let bytes = body.map_or_else(Vec::new, |value| serde_json::to_vec(&value).unwrap()); if !bytes.is_empty() { builder = builder.header("content-type", "application/json"); @@ -130,7 +134,7 @@ async fn login_verifies_credential_hashed_under_a_rotated_out_pepper() { "invite_token": invite_token, "email": "rotated@example.test", "password": "correct horse battery staple", - "client_kind": "desktop" + "client_kind": "browser" })), ) .await; @@ -156,7 +160,7 @@ async fn login_verifies_credential_hashed_under_a_rotated_out_pepper() { Some(json!({ "email": "rotated@example.test", "password": "correct horse battery staple", - "client_kind": "desktop" + "client_kind": "browser" })), ) .await; @@ -179,7 +183,7 @@ async fn login_verifies_credential_hashed_under_a_rotated_out_pepper() { Some(json!({ "email": "rotated@example.test", "password": "correct horse battery staple", - "client_kind": "desktop" + "client_kind": "browser" })), ) .await; @@ -221,7 +225,7 @@ async fn login_verifies_credential_hashed_under_a_rotated_out_pepper() { Some(json!({ "email": "rotated@example.test", "password": "correct horse battery staple", - "client_kind": "desktop" + "client_kind": "browser" })), ) .await; diff --git a/crates/frameshift-server/tests/mocks/catalog.rs b/crates/frameshift-server/tests/mocks/catalog.rs index 389117c..a3c8e54 100644 --- a/crates/frameshift-server/tests/mocks/catalog.rs +++ b/crates/frameshift-server/tests/mocks/catalog.rs @@ -12,6 +12,7 @@ use std::collections::HashMap; use std::sync::{Arc, RwLock}; +use std::time::Duration as StdDuration; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; @@ -21,12 +22,22 @@ use frameshift_catalog::error::{CatalogError, HealthStatus}; use frameshift_catalog::filters::{PackSearchFilters, PackSearchResult}; use frameshift_catalog::identity::Ed25519PublicKey; use frameshift_catalog::records::{ - AccountInviteIssueRequest, AccountInviteRecord, AccountInviteRequestRecord, - AccountInviteReviewRequest, AccountInviteStatus, AccountPasswordCredentialRecord, - AccountPasswordRehashRequest, AccountRecord, AccountSessionRecord, AccountStatus, - AccountStatusChangeRequest, AuthorRecord, LocalAccountRegistrationRequest, - LocalAccountRegistrationResult, MembershipState, PackRecord, PackVersionRecord, PlatformRole, - PlatformRoleAssignmentRequest, PlatformRoleRecord, PlatformRoleRevocationRequest, + AccountAuthAuditEventRecord, AccountInviteIssueRequest, AccountInviteRecord, + AccountInviteRequestRecord, AccountInviteReviewRequest, AccountInviteStatus, + AccountMfaActivationRequest, AccountMfaAuthenticatorRecord, AccountMfaAuthenticatorState, + AccountMfaChallengeCompletionRequest, AccountMfaChallengeCompletionResult, + AccountMfaChallengeCreationRequest, AccountMfaChallengeProof, AccountMfaDisableRequest, + AccountMfaEnrollmentRequest, AccountMfaLoginChallengeRecord, AccountPasswordCredentialRecord, + AccountPasswordRehashRequest, AccountRecord, AccountSessionCreationRequest, + AccountSessionIssuance, AccountSessionRecord, AccountSessionRefreshRequest, + AccountSessionRefreshResult, AccountStatus, AccountStatusChangeRequest, AuthorRecord, + EncryptedPasswordRecoveryDelivery, LocalAccountRegistrationRequest, + LocalAccountRegistrationResult, MembershipState, NativeAuthorizationCodeCreationRequest, + NativeAuthorizationCodeExchangeRequest, NativeAuthorizationCodeExchangeResult, + NativeAuthorizationCodeRecord, PackRecord, PackVersionRecord, + PasswordRecoveryCompletionRequest, PasswordRecoveryDeliveryClaimRequest, + PasswordRecoveryDeliveryKind, PasswordRecoveryDeliveryRecord, PasswordRecoveryEnqueueRequest, + PlatformRole, PlatformRoleAssignmentRequest, PlatformRoleRecord, PlatformRoleRevocationRequest, PlatformRoleState, PublicationAppealCaseRecord, PublicationAppealCursor, PublicationAppealDisposition, PublicationAppealRecord, PublicationAppealRequest, PublicationAppealResolutionRecord, PublicationAppealResolutionRequest, PublicationIntentRecord, @@ -40,6 +51,7 @@ use frameshift_catalog::records::{ PublisherSuspensionRequest, }; use frameshift_catalog::status::{PackStatus, TombstoneRecord}; +use frameshift_catalog::AccountSessionClientKind; // Reuse the exact same version-precedence comparator the Postgres adapter // uses for `register_pack_version`'s `latest_version` selection, so the // mock's tombstone head-recompute can never drift from the real ordering. @@ -47,6 +59,51 @@ use frameshift_catalog::PublishQuota; use frameshift_catalog_postgres::backend::semver_gt; use frameshift_pack::ObjectHash; +/// Digest-only reset-token lifecycle retained by the in-memory catalog double. +#[derive(Clone)] +pub struct MockPasswordRecoveryToken { + /// Stable token-row identifier. + pub id: uuid::Uuid, + /// Account authorized by this reset token. + pub account_id: uuid::Uuid, + /// SHA-256 digest of the caller-held bearer. + pub token_digest: Vec, + /// Exclusive token consumption deadline. + pub expires_at: DateTime, + /// Successful one-time consumption timestamp. + pub consumed_at: Option>, + /// Explicit supersession timestamp. + pub revoked_at: Option>, + /// Request admission timestamp. + pub created_at: DateTime, +} + +/// Digest-only refresh generation retained by the in-memory catalog double. +#[derive(Clone)] +pub struct MockRefreshToken { + /// Stable refresh-generation identifier. + pub id: uuid::Uuid, + /// Session family owning this generation. + pub session_id: uuid::Uuid, + /// SHA-256 digest of the caller-held refresh token. + pub token_digest: Vec, + /// Exclusive token consumption deadline. + pub expires_at: DateTime, + /// Successful consumption timestamp used to detect replay. + pub consumed_at: Option>, +} + +/// Digest-only recovery code retained by the in-memory catalog double. +#[derive(Clone)] +pub struct MockMfaRecoveryCode { + /// Authenticator whose activation created the code. + pub authenticator_id: uuid::Uuid, + /// Account that owns the code. + pub account_id: uuid::Uuid, + /// Whether a successful challenge already consumed the code. + pub consumed_at: Option>, +} + /// Shared mutable state for [`MockCatalog`]. /// /// Wrapped in `Arc>` so that the catalog can be cloned @@ -65,6 +122,30 @@ pub struct MockState { /// Revocable first-party sessions keyed by stable identifier. pub account_sessions: HashMap, + /// Digest-only refresh generations keyed by their token digest. + pub account_refresh_tokens: HashMap, MockRefreshToken>, + + /// Encrypted MFA authenticators keyed by stable identifier. + pub account_mfa_authenticators: HashMap, + + /// Digest-only recovery codes keyed by their token digest. + pub account_mfa_recovery_codes: HashMap, MockMfaRecoveryCode>, + + /// Digest-only MFA login challenges keyed by their token digest. + pub account_mfa_challenges: HashMap, AccountMfaLoginChallengeRecord>, + + /// Digest-only native authorization codes keyed by their token digest. + pub native_authorization_codes: HashMap, NativeAuthorizationCodeRecord>, + + /// Immutable sanitized first-party authentication audit events. + pub account_auth_audit_events: Vec, + + /// Digest-only password reset tokens keyed by stable identifier. + pub password_recovery_tokens: HashMap, + + /// Encrypted recovery outbox rows keyed by provider idempotency identifier. + pub password_recovery_deliveries: HashMap, + /// OIDC-backed accounts keyed by internal identifier. pub accounts: HashMap, @@ -164,6 +245,8 @@ pub struct MockState { pub struct MockCatalog { /// The shared mutable fake catalog state. pub state: Arc>, + /// Per-email enqueue latency used to prove response timing independence. + password_recovery_enqueue_delays: Arc>>, } /// Constructors for the in-memory catalog test double. @@ -172,8 +255,18 @@ impl MockCatalog { pub fn new() -> Self { Self { state: Arc::new(RwLock::new(MockState::default())), + password_recovery_enqueue_delays: Arc::new(RwLock::new(HashMap::new())), } } + + /// Delay one normalized recovery-email enqueue without blocking mock state access. + #[allow(dead_code)] + pub fn delay_password_recovery_enqueue(&self, normalized_email: &str, delay: StdDuration) { + self.password_recovery_enqueue_delays + .write() + .unwrap() + .insert(normalized_email.to_string(), delay); + } } /// Default construction of an empty mock catalog. @@ -184,6 +277,48 @@ impl Default for MockCatalog { } } +/// Persist one mock access session and its initial digest-only refresh generation. +fn insert_mock_session_issuance( + state: &mut MockState, + issuance: AccountSessionIssuance, +) -> Result { + let session = issuance.session; + if state.account_sessions.contains_key(&session.id) + || state + .account_refresh_tokens + .values() + .any(|token| token.id == issuance.refresh_token_id) + || state + .account_refresh_tokens + .contains_key(&issuance.refresh_token_digest) + { + return Err(CatalogError::Conflict { + kind: "account_session", + key: session.id.to_string(), + }); + } + let refresh = MockRefreshToken { + id: issuance.refresh_token_id, + session_id: session.id, + token_digest: issuance.refresh_token_digest, + expires_at: issuance.refresh_expires_at, + consumed_at: None, + }; + state + .account_refresh_tokens + .insert(refresh.token_digest.clone(), refresh); + state.account_sessions.insert(session.id, session.clone()); + Ok(session) +} + +/// Return whether the mock account exists and remains active. +fn mock_account_is_active(state: &MockState, account_id: uuid::Uuid) -> bool { + state + .accounts + .get(&account_id) + .is_some_and(|account| account.status == AccountStatus::Active) +} + /// Validate optional audit records before applying an in-memory mutation. fn validate_audit( event: Option<&PublisherAuditEventRecord>, @@ -205,6 +340,36 @@ fn validate_audit( Ok(()) } +/// Expand a caller-encrypted envelope into one pending mock outbox record. +fn mock_recovery_delivery( + account_id: uuid::Uuid, + recipient: String, + kind: PasswordRecoveryDeliveryKind, + delivery: EncryptedPasswordRecoveryDelivery, + created_at: DateTime, +) -> PasswordRecoveryDeliveryRecord { + PasswordRecoveryDeliveryRecord { + id: delivery.id, + account_id, + kind, + recipient, + ciphertext: delivery.ciphertext, + nonce: delivery.nonce, + key_version: delivery.key_version, + attempt_count: 0, + last_attempt_at: None, + claim_id: None, + claimed_at: None, + next_attempt_at: created_at, + expires_at: delivery.expires_at, + sent_at: None, + provider_message_id: None, + failed_at: None, + last_error_code: None, + created_at, + } +} + #[async_trait] /// In-memory implementation of every catalog operation used by server tests. impl CatalogBackend for MockCatalog { @@ -380,12 +545,11 @@ impl CatalogBackend for MockCatalog { request.credential.normalized_email.clone(), request.credential, ); - state - .account_sessions - .insert(request.session.id, request.session.clone()); + let session = insert_mock_session_issuance(&mut state, request.session)?; + state.account_auth_audit_events.push(request.audit_event); Ok(LocalAccountRegistrationResult { account: request.account, - session: request.session, + session, }) } @@ -436,23 +600,311 @@ impl CatalogBackend for MockCatalog { Ok(true) } - /// Create one mock first-party session. - async fn create_account_session( + /// Atomically create one eligible mock reset token and encrypted delivery. + async fn enqueue_account_password_recovery( &self, - record: AccountSessionRecord, - ) -> Result<(), CatalogError> { + request: PasswordRecoveryEnqueueRequest, + ) -> Result { + let delay = self + .password_recovery_enqueue_delays + .read() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))? + .get(&request.normalized_email) + .copied(); + if let Some(delay) = delay { + tokio::time::sleep(delay).await; + } let mut state = self .state .write() .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; - if state.account_sessions.contains_key(&record.id) { + let Some(credential) = state + .account_password_credentials + .get(&request.normalized_email) + .cloned() + else { + return Ok(false); + }; + let eligible = credential.email_verified_at.is_some() + && state + .accounts + .get(&credential.account_id) + .is_some_and(|account| account.status == AccountStatus::Active); + if !eligible + || state.password_recovery_tokens.values().any(|token| { + token.account_id == credential.account_id + && token.created_at > request.cooldown_cutoff + }) + { + return Ok(false); + } + if state + .password_recovery_tokens + .contains_key(&request.token_id) + || state + .password_recovery_deliveries + .contains_key(&request.delivery.id) + { return Err(CatalogError::Conflict { - kind: "account_session", - key: record.id.to_string(), + kind: "password_recovery", + key: request.token_id.to_string(), }); } - state.account_sessions.insert(record.id, record); - Ok(()) + + for token in state.password_recovery_tokens.values_mut() { + if token.account_id == credential.account_id + && token.consumed_at.is_none() + && token.revoked_at.is_none() + { + token.revoked_at = Some(request.requested_at); + } + } + let token = MockPasswordRecoveryToken { + id: request.token_id, + account_id: credential.account_id, + token_digest: request.token_digest, + expires_at: request.token_expires_at, + consumed_at: None, + revoked_at: None, + created_at: request.requested_at, + }; + let delivery = mock_recovery_delivery( + credential.account_id, + credential.normalized_email, + PasswordRecoveryDeliveryKind::Reset, + request.delivery, + request.requested_at, + ); + state.password_recovery_tokens.insert(token.id, token); + state + .password_recovery_deliveries + .insert(delivery.id, delivery); + Ok(true) + } + + /// Atomically consume one mock token, replace its password, revoke sessions, and enqueue notice. + async fn complete_account_password_recovery( + &self, + request: PasswordRecoveryCompletionRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(token) = state + .password_recovery_tokens + .values() + .find(|token| token.token_digest == request.token_digest) + .cloned() + else { + return Ok(false); + }; + if token.consumed_at.is_some() + || token.revoked_at.is_some() + || token.expires_at <= request.completed_at + || !state + .accounts + .get(&token.account_id) + .is_some_and(|account| account.status == AccountStatus::Active) + { + return Ok(false); + } + let Some(normalized_email) = state + .account_password_credentials + .iter() + .find(|(_, credential)| { + credential.account_id == token.account_id && credential.email_verified_at.is_some() + }) + .map(|(email, _)| email.clone()) + else { + return Ok(false); + }; + if state + .password_recovery_deliveries + .contains_key(&request.delivery.id) + { + return Err(CatalogError::Conflict { + kind: "password_recovery_delivery", + key: request.delivery.id.to_string(), + }); + } + + for candidate in state.password_recovery_tokens.values_mut() { + if candidate.account_id != token.account_id { + continue; + } + if candidate.id == token.id { + candidate.consumed_at = Some(request.completed_at); + } else if candidate.consumed_at.is_none() && candidate.revoked_at.is_none() { + candidate.revoked_at = Some(request.completed_at); + } + } + let credential = state + .account_password_credentials + .get_mut(&normalized_email) + .expect("credential identity was resolved while holding the write lock"); + credential.password_hash = request.new_password_hash; + credential.password_version = request.new_password_version; + credential.pepper_version = request.new_pepper_version; + credential.password_changed_at = request.completed_at; + credential.updated_at = request.completed_at; + for session in state.account_sessions.values_mut() { + if session.account_id == token.account_id && session.revoked_at.is_none() { + session.revoked_at = Some(request.completed_at); + } + } + let delivery = mock_recovery_delivery( + token.account_id, + normalized_email, + PasswordRecoveryDeliveryKind::PasswordChanged, + request.delivery, + request.completed_at, + ); + state + .password_recovery_deliveries + .insert(delivery.id, delivery); + Ok(true) + } + + /// Lease a bounded deterministic batch of ready mock recovery deliveries. + async fn claim_password_recovery_deliveries( + &self, + request: PasswordRecoveryDeliveryClaimRequest, + ) -> Result, CatalogError> { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let mut ids: Vec<_> = state + .password_recovery_deliveries + .values() + .filter(|delivery| { + delivery.sent_at.is_none() + && delivery.failed_at.is_none() + && delivery.expires_at > request.claimed_at + && delivery.next_attempt_at <= request.claimed_at + && (delivery.claim_id.is_none() + || delivery + .claimed_at + .is_some_and(|claimed_at| claimed_at <= request.stale_before)) + }) + .map(|delivery| delivery.id) + .collect(); + ids.sort_by_key(|id| { + let delivery = &state.password_recovery_deliveries[id]; + (delivery.next_attempt_at, delivery.created_at, delivery.id) + }); + ids.truncate(request.limit as usize); + + let mut claimed = Vec::with_capacity(ids.len()); + for id in ids { + let delivery = state.password_recovery_deliveries.get_mut(&id).unwrap(); + delivery.claim_id = Some(request.claim_id); + delivery.claimed_at = Some(request.claimed_at); + delivery.last_attempt_at = Some(request.claimed_at); + delivery.attempt_count = delivery.attempt_count.saturating_add(1); + claimed.push(delivery.clone()); + } + Ok(claimed) + } + + /// Acknowledge one successful mock delivery under its exact claim fence. + async fn mark_password_recovery_delivery_sent( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + sent_at: DateTime, + provider_message_id: String, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(delivery) = state.password_recovery_deliveries.get_mut(&delivery_id) else { + return Ok(false); + }; + if delivery.claim_id != Some(claim_id) + || delivery.sent_at.is_some() + || delivery.failed_at.is_some() + { + return Ok(false); + } + delivery.claim_id = None; + delivery.claimed_at = None; + delivery.sent_at = Some(sent_at); + delivery.provider_message_id = Some(provider_message_id); + delivery.last_error_code = None; + Ok(true) + } + + /// Release one mock delivery claim for a scheduled retry. + async fn retry_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + next_attempt_at: DateTime, + last_error_code: String, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(delivery) = state.password_recovery_deliveries.get_mut(&delivery_id) else { + return Ok(false); + }; + if delivery.claim_id != Some(claim_id) + || delivery.sent_at.is_some() + || delivery.failed_at.is_some() + { + return Ok(false); + } + delivery.claim_id = None; + delivery.claimed_at = None; + delivery.next_attempt_at = next_attempt_at; + delivery.last_error_code = Some(last_error_code); + Ok(true) + } + + /// Mark one mock delivery terminal under its exact claim fence. + async fn fail_password_recovery_delivery( + &self, + delivery_id: uuid::Uuid, + claim_id: uuid::Uuid, + failed_at: DateTime, + last_error_code: String, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(delivery) = state.password_recovery_deliveries.get_mut(&delivery_id) else { + return Ok(false); + }; + if delivery.claim_id != Some(claim_id) + || delivery.sent_at.is_some() + || delivery.failed_at.is_some() + { + return Ok(false); + } + delivery.claim_id = None; + delivery.claimed_at = None; + delivery.failed_at = Some(failed_at); + delivery.last_error_code = Some(last_error_code); + Ok(true) + } + + /// Create one mock first-party session. + async fn create_account_session( + &self, + request: AccountSessionCreationRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let session = insert_mock_session_issuance(&mut state, request.issuance)?; + state.account_auth_audit_events.push(request.audit_event); + Ok(session) } /// Resolve one active mock first-party session. @@ -469,6 +921,7 @@ impl CatalogBackend for MockCatalog { .find(|session| { session.token_digest == token_digest && session.revoked_at.is_none() + && session.access_expires_at > now && session.idle_expires_at > now && session.absolute_expires_at > now }) @@ -526,6 +979,484 @@ impl CatalogBackend for MockCatalog { Ok(()) } + /// Rotate one mock refresh generation or revoke its family after replay. + async fn refresh_account_session( + &self, + request: AccountSessionRefreshRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(refresh) = state + .account_refresh_tokens + .get(&request.presented_refresh_token_digest) + .cloned() + else { + return Ok(AccountSessionRefreshResult::Rejected); + }; + let Some(session) = state.account_sessions.get(&refresh.session_id).cloned() else { + return Ok(AccountSessionRefreshResult::Rejected); + }; + + if refresh.consumed_at.is_some() { + if let Some(stored_session) = state.account_sessions.get_mut(&session.id) { + stored_session.revoked_at.get_or_insert(request.rotated_at); + } + state + .account_auth_audit_events + .push(request.replay_audit_event); + return Ok(AccountSessionRefreshResult::ReplayRevoked); + } + if refresh.expires_at <= request.rotated_at + || !mock_account_is_active(&state, session.account_id) + || session.revoked_at.is_some() + || session.idle_expires_at <= request.rotated_at + || session.absolute_expires_at <= request.rotated_at + || request.replacement_access_expires_at > session.absolute_expires_at + || request.replacement_idle_expires_at > session.absolute_expires_at + || request.replacement_refresh_expires_at > session.absolute_expires_at + { + return Ok(AccountSessionRefreshResult::Rejected); + } + if state + .account_refresh_tokens + .contains_key(&request.replacement_refresh_token_digest) + || state + .account_refresh_tokens + .values() + .any(|token| token.id == request.replacement_refresh_token_id) + { + return Err(CatalogError::Conflict { + kind: "account_session_refresh", + key: request.replacement_refresh_token_id.to_string(), + }); + } + + state + .account_refresh_tokens + .get_mut(&request.presented_refresh_token_digest) + .expect("refresh generation was resolved while holding the write lock") + .consumed_at = Some(request.rotated_at); + let replacement = MockRefreshToken { + id: request.replacement_refresh_token_id, + session_id: session.id, + token_digest: request.replacement_refresh_token_digest, + expires_at: request.replacement_refresh_expires_at, + consumed_at: None, + }; + state + .account_refresh_tokens + .insert(replacement.token_digest.clone(), replacement); + let stored_session = state + .account_sessions + .get_mut(&session.id) + .expect("session was resolved while holding the write lock"); + stored_session.token_digest = request.replacement_access_token_digest; + stored_session.last_seen_at = request.rotated_at; + stored_session.access_expires_at = request.replacement_access_expires_at; + stored_session.idle_expires_at = request.replacement_idle_expires_at; + let rotated = stored_session.clone(); + state + .account_auth_audit_events + .push(request.success_audit_event); + Ok(AccountSessionRefreshResult::Rotated(rotated)) + } + + /// Retrieve the active mock authenticator for one account. + async fn get_active_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + ) -> Result { + self.state + .read() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))? + .account_mfa_authenticators + .values() + .find(|authenticator| { + authenticator.account_id == account_id + && authenticator.state == AccountMfaAuthenticatorState::Active + }) + .cloned() + .ok_or_else(|| CatalogError::NotFound { + kind: "account_mfa_authenticator", + key: account_id.to_string(), + }) + } + + /// Retrieve one exact unexpired pending mock authenticator. + async fn get_pending_account_mfa_authenticator( + &self, + account_id: uuid::Uuid, + authenticator_id: uuid::Uuid, + now: DateTime, + ) -> Result { + self.state + .read() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))? + .account_mfa_authenticators + .get(&authenticator_id) + .filter(|authenticator| { + authenticator.account_id == account_id + && authenticator.state == AccountMfaAuthenticatorState::Pending + && authenticator + .pending_expires_at + .is_some_and(|expires_at| expires_at > now) + }) + .cloned() + .ok_or_else(|| CatalogError::NotFound { + kind: "account_mfa_authenticator", + key: authenticator_id.to_string(), + }) + } + + /// Replace pending mock enrollment state and retain its success audit. + async fn begin_account_mfa_enrollment( + &self, + request: AccountMfaEnrollmentRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + if !mock_account_is_active(&state, request.authenticator.account_id) { + return Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: request.authenticator.account_id.to_string(), + }); + } + for authenticator in state.account_mfa_authenticators.values_mut() { + if authenticator.account_id == request.authenticator.account_id + && authenticator.state == AccountMfaAuthenticatorState::Pending + { + authenticator.state = AccountMfaAuthenticatorState::Disabled; + authenticator.pending_expires_at = None; + authenticator.disabled_at = Some(request.authenticator.created_at); + } + } + let authenticator = request.authenticator; + state + .account_mfa_authenticators + .insert(authenticator.id, authenticator.clone()); + state.account_auth_audit_events.push(request.audit_event); + Ok(authenticator) + } + + /// Activate one pending mock authenticator and replace its recovery codes. + async fn activate_account_mfa( + &self, + request: AccountMfaActivationRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let pending_is_valid = state + .account_mfa_authenticators + .get(&request.authenticator_id) + .is_some_and(|authenticator| { + authenticator.account_id == request.account_id + && authenticator.state == AccountMfaAuthenticatorState::Pending + && authenticator + .pending_expires_at + .is_some_and(|expires_at| expires_at > request.activated_at) + && authenticator + .last_used_timestep + .is_none_or(|timestep| request.verified_timestep > timestep) + }); + if !pending_is_valid || !mock_account_is_active(&state, request.account_id) { + return Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: request.authenticator_id.to_string(), + }); + } + for authenticator in state.account_mfa_authenticators.values_mut() { + if authenticator.account_id == request.account_id + && authenticator.state == AccountMfaAuthenticatorState::Active + { + authenticator.state = AccountMfaAuthenticatorState::Disabled; + authenticator.disabled_at = Some(request.activated_at); + } + } + let authenticator = state + .account_mfa_authenticators + .get_mut(&request.authenticator_id) + .expect("pending authenticator was resolved while holding the write lock"); + authenticator.state = AccountMfaAuthenticatorState::Active; + authenticator.pending_expires_at = None; + authenticator.last_used_timestep = Some(request.verified_timestep); + authenticator.activated_at = Some(request.activated_at); + let activated = authenticator.clone(); + state + .account_mfa_recovery_codes + .retain(|_, code| code.account_id != request.account_id); + for seed in request.recovery_codes { + state.account_mfa_recovery_codes.insert( + seed.code_digest, + MockMfaRecoveryCode { + authenticator_id: request.authenticator_id, + account_id: request.account_id, + consumed_at: None, + }, + ); + } + state.account_auth_audit_events.push(request.audit_event); + Ok(activated) + } + + /// Disable active mock MFA unless a platform role requires it. + async fn disable_account_mfa( + &self, + request: AccountMfaDisableRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + if state.platform_roles.iter().any(|role| { + role.account_id == request.account_id && role.state == PlatformRoleState::Active + }) { + return Err(CatalogError::Validation( + "active privileged roles require MFA".to_string(), + )); + } + let mut disabled = false; + for authenticator in state.account_mfa_authenticators.values_mut() { + if authenticator.account_id == request.account_id + && authenticator.state == AccountMfaAuthenticatorState::Active + { + authenticator.state = AccountMfaAuthenticatorState::Disabled; + authenticator.disabled_at = Some(request.disabled_at); + disabled = true; + } + } + if disabled { + state.account_auth_audit_events.push(request.audit_event); + } + Ok(disabled) + } + + /// Create one digest-only mock MFA login challenge. + async fn create_account_mfa_challenge( + &self, + request: AccountMfaChallengeCreationRequest, + ) -> Result<(), CatalogError> { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let active_authenticator_count = state + .account_mfa_authenticators + .values() + .filter(|authenticator| { + authenticator.account_id == request.challenge.account_id + && authenticator.state == AccountMfaAuthenticatorState::Active + }) + .count(); + if !mock_account_is_active(&state, request.challenge.account_id) + || active_authenticator_count != 1 + { + return Err(CatalogError::Unauthorized { + kind: "account_mfa_authenticator", + key: request.challenge.account_id.to_string(), + }); + } + if state + .account_mfa_challenges + .contains_key(&request.challenge.token_digest) + { + return Err(CatalogError::Conflict { + kind: "account_mfa_challenge", + key: request.challenge.id.to_string(), + }); + } + state + .account_mfa_challenges + .insert(request.challenge.token_digest.clone(), request.challenge); + state.account_auth_audit_events.push(request.audit_event); + Ok(()) + } + + /// Consume one mock MFA challenge and issue a verified session family. + async fn complete_account_mfa_challenge( + &self, + request: AccountMfaChallengeCompletionRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(challenge) = state + .account_mfa_challenges + .get(&request.challenge_token_digest) + .cloned() + else { + return Ok(AccountMfaChallengeCompletionResult::Rejected); + }; + let Some(authenticator) = state + .account_mfa_authenticators + .get(&request.authenticator_id) + .cloned() + else { + return Ok(AccountMfaChallengeCompletionResult::Rejected); + }; + if challenge.consumed_at.is_some() + || challenge.expires_at <= request.completed_at + || challenge.account_id != request.issuance.session.account_id + || challenge.client_kind != request.issuance.session.client_kind + || !mock_account_is_active(&state, challenge.account_id) + || authenticator.account_id != challenge.account_id + || authenticator.state != AccountMfaAuthenticatorState::Active + || request.issuance.session.mfa_verified_at != Some(request.completed_at) + { + return Ok(AccountMfaChallengeCompletionResult::Rejected); + } + + match &request.proof { + AccountMfaChallengeProof::TotpTimestep(timestep) => { + if *timestep < 0 + || authenticator + .last_used_timestep + .is_some_and(|last_used| *timestep <= last_used) + { + return Ok(AccountMfaChallengeCompletionResult::Rejected); + } + } + AccountMfaChallengeProof::RecoveryCodeDigest(digest) => { + let usable = state + .account_mfa_recovery_codes + .get(digest) + .is_some_and(|code| { + code.authenticator_id == authenticator.id + && code.account_id == challenge.account_id + && code.consumed_at.is_none() + }); + if !usable { + return Ok(AccountMfaChallengeCompletionResult::Rejected); + } + } + } + if state + .account_sessions + .contains_key(&request.issuance.session.id) + || state + .account_refresh_tokens + .contains_key(&request.issuance.refresh_token_digest) + { + return Err(CatalogError::Conflict { + kind: "account_session", + key: request.issuance.session.id.to_string(), + }); + } + + match &request.proof { + AccountMfaChallengeProof::TotpTimestep(timestep) => { + state + .account_mfa_authenticators + .get_mut(&authenticator.id) + .expect("active authenticator was resolved while holding the write lock") + .last_used_timestep = Some(*timestep); + } + AccountMfaChallengeProof::RecoveryCodeDigest(digest) => { + state + .account_mfa_recovery_codes + .get_mut(digest) + .expect("recovery code was resolved while holding the write lock") + .consumed_at = Some(request.completed_at); + } + } + state + .account_mfa_challenges + .get_mut(&request.challenge_token_digest) + .expect("MFA challenge was resolved while holding the write lock") + .consumed_at = Some(request.completed_at); + let session = insert_mock_session_issuance(&mut state, request.issuance)?; + state.account_auth_audit_events.push(request.audit_event); + Ok(AccountMfaChallengeCompletionResult::Completed(session)) + } + + /// Create one exactly bound digest-only mock native authorization code. + async fn create_native_authorization_code( + &self, + request: NativeAuthorizationCodeCreationRequest, + ) -> Result<(), CatalogError> { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + if !mock_account_is_active(&state, request.code.account_id) { + return Err(CatalogError::Unauthorized { + kind: "native_authorization_code", + key: request.code.account_id.to_string(), + }); + } + if state + .native_authorization_codes + .contains_key(&request.code.token_digest) + { + return Err(CatalogError::Conflict { + kind: "native_authorization_code", + key: request.code.id.to_string(), + }); + } + state + .native_authorization_codes + .insert(request.code.token_digest.clone(), request.code); + state.account_auth_audit_events.push(request.audit_event); + Ok(()) + } + + /// Exchange one exactly bound mock native code for a session family. + async fn exchange_native_authorization_code( + &self, + request: NativeAuthorizationCodeExchangeRequest, + ) -> Result { + let mut state = self + .state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))?; + let Some(code) = state + .native_authorization_codes + .get(&request.code_token_digest) + .cloned() + else { + return Ok(NativeAuthorizationCodeExchangeResult::Rejected); + }; + if code.consumed_at.is_some() + || code.expires_at <= request.exchanged_at + || code.client_kind == AccountSessionClientKind::Browser + || code.client_kind != request.client_kind + || code.redirect_uri != request.redirect_uri + || code.pkce_challenge != request.pkce_challenge + || code.account_id != request.issuance.session.account_id + || code.mfa_verified_at != request.issuance.session.mfa_verified_at + || !mock_account_is_active(&state, code.account_id) + { + return Ok(NativeAuthorizationCodeExchangeResult::Rejected); + } + state + .native_authorization_codes + .get_mut(&request.code_token_digest) + .expect("native code was resolved while holding the write lock") + .consumed_at = Some(request.exchanged_at); + let session = insert_mock_session_issuance(&mut state, request.issuance)?; + state.account_auth_audit_events.push(request.audit_event); + Ok(NativeAuthorizationCodeExchangeResult::Exchanged(session)) + } + + /// Append one sanitized mock authentication audit event. + async fn append_account_auth_audit_event( + &self, + event: AccountAuthAuditEventRecord, + ) -> Result<(), CatalogError> { + self.state + .write() + .map_err(|error| CatalogError::BackendError(error.to_string().into()))? + .account_auth_audit_events + .push(event); + Ok(()) + } + /// Create an account while enforcing ID and OIDC identity uniqueness. async fn create_account(&self, record: AccountRecord) -> Result<(), CatalogError> { let mut state = self diff --git a/crates/frameshift-studio/src/lib.rs b/crates/frameshift-studio/src/lib.rs index a2a11d4..ebc2289 100644 --- a/crates/frameshift-studio/src/lib.rs +++ b/crates/frameshift-studio/src/lib.rs @@ -26,6 +26,15 @@ pub const DRAFT_SCHEMA_VERSION: u32 = 1; /// Current schema version for serialized exact-draft validation reports. pub const DRAFT_VALIDATION_SCHEMA_VERSION: u32 = 1; +/// Minimum conformance threshold that can authorize publication review. +pub const MIN_PUBLICATION_CONFORMANCE_THRESHOLD: f32 = 0.8; + +/// Current schema version for persisted publication-validation attestations. +const VALIDATION_ATTESTATION_SCHEMA_VERSION: u32 = 1; + +/// Current policy version required by exact publication review. +const PUBLICATION_VALIDATION_POLICY_VERSION: u32 = 1; + /// Filename holding private Creator Studio draft metadata. const METADATA_FILENAME: &str = "draft.json"; @@ -53,12 +62,28 @@ pub struct Draft { pub title: String, /// Monotonic local mutation counter. pub revision: u64, + /// Successful publication validation bound to the exact current inventory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validation: Option, /// Human confirmation bound to an exact valid file inventory. pub review: Option, /// Explicit publish intent bound to the same reviewed inventory. pub submission_intent: Option, } +/// Minimal path-free proof that exact draft bytes passed publication validation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidationConfirmation { + /// Version of this private attestation's serialized contract. + pub schema_version: u32, + /// Publication-review policy version applied to the successful result. + pub policy_version: u32, + /// Draft revision whose exact bytes passed validation. + pub revision: u64, + /// Deterministic hash of the validated public file inventory. + pub inventory_hash: String, +} + /// Human confirmation of the exact files represented by an inventory hash. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReviewConfirmation { @@ -350,6 +375,9 @@ pub enum StudioError { /// Review requires a fully valid publication report. #[error("draft is not valid for review")] InvalidForReview, + /// Review requires a current successful validation of the exact draft bytes. + #[error("draft validation is not current for review")] + ValidationNotCurrent, /// Confirmation did not match the exact inventory presented for review. #[error("review inventory hash does not match current content")] ReviewHashMismatch, @@ -796,6 +824,7 @@ impl Studio { if status.publication.inventory_hash != expected_inventory_hash { return Err(StudioError::ReviewHashMismatch); } + require_current_validation(&status)?; self.freeze_status(id, status) } @@ -807,6 +836,7 @@ impl Studio { ) -> Result { let status = self.status(id)?; validate_review_binding(&status.publication, &binding)?; + require_current_validation(&status)?; let snapshot = self.freeze_status(id, status)?; let manifest_file = snapshot .files @@ -836,6 +866,7 @@ impl Studio { return Err(StudioError::InvalidForReview); } validate_review_binding(&status.publication, &binding)?; + require_current_validation(&status)?; status.draft.review = Some(ReviewConfirmation { revision: status.draft.revision, inventory_hash: status.publication.inventory_hash.clone(), @@ -854,6 +885,7 @@ impl Studio { binding: PublicationReviewBinding, ) -> Result { let mut status = self.status(id)?; + require_current_validation(&status)?; if !status.review_current { return Err(StudioError::ReviewNotCurrent); } @@ -883,6 +915,7 @@ impl Studio { binding: PublicationReviewBinding, ) -> Result { let status = self.status(id)?; + require_current_validation(&status)?; if !status.submission_intent_current { return Err(StudioError::SubmissionIntentNotCurrent); } @@ -923,8 +956,7 @@ impl Studio { "publication policy must pass before conformance execution", ), ); - self.ensure_status_current(id, report.revision, &report.publication)?; - return Ok(report); + return self.finish_validation(id, report); } let snapshot = self.freeze_status(id, status)?; @@ -964,14 +996,17 @@ impl Studio { { Some(bundle) => bundle, None => { - return Ok(validation_report_from_snapshot( - &snapshot, - blocked_conformance( - threshold, - "conformance.bundle_invalid", - "conformance bundle does not match the shared schema", + return self.finish_validation( + id, + validation_report_from_snapshot( + &snapshot, + blocked_conformance( + threshold, + "conformance.bundle_invalid", + "conformance bundle does not match the shared schema", + ), ), - )); + ); } }; if bundle.name != manifest.name || bundle.version != manifest.version { @@ -1016,8 +1051,37 @@ impl Studio { } }; - self.ensure_status_current(id, snapshot.revision, &snapshot.publication)?; - Ok(validation_report_from_snapshot(&snapshot, conformance)) + self.finish_validation(id, validation_report_from_snapshot(&snapshot, conformance)) + } + + /// Persist or clear publication authority for one exact completed validation. + fn finish_validation( + &self, + id: &str, + report: DraftValidationReport, + ) -> Result { + let mut current = self.status(id)?; + if current.draft.revision != report.revision || current.publication != report.publication { + return Err(StudioError::SnapshotChanged); + } + + let authorizes_publication = + report.valid && report.conformance.threshold >= MIN_PUBLICATION_CONFORMANCE_THRESHOLD; + current.draft.validation = authorizes_publication.then(|| ValidationConfirmation { + schema_version: VALIDATION_ATTESTATION_SCHEMA_VERSION, + policy_version: PUBLICATION_VALIDATION_POLICY_VERSION, + revision: report.revision, + inventory_hash: report.inventory_hash.clone(), + }); + if !authorizes_publication { + current.draft.review = None; + current.draft.submission_intent = None; + } + + let paths = self.draft_paths(id)?; + write_json_atomic(&paths.metadata, ¤t.draft, true)?; + self.ensure_status_current(id, report.revision, &report.publication)?; + Ok(report) } /// Freeze every scanner-inventoried regular file from one fresh status. @@ -1107,6 +1171,7 @@ impl Studio { id: id.to_string(), title: title.to_string(), revision: 0, + validation: None, review: None, submission_intent: None, }; @@ -1440,14 +1505,35 @@ fn invalidate_before_mutation(draft: &mut Draft) -> Result<(), StudioError> { .revision .checked_add(1) .ok_or_else(|| StudioError::InvalidMetadata("revision overflow".to_string()))?; + draft.validation = None; draft.review = None; draft.submission_intent = None; Ok(()) } +/// Return whether persisted validation authorizes the exact current inventory. +fn validation_matches(draft: &Draft, report: &PublicationReport) -> bool { + report.valid + && draft.validation.as_ref().is_some_and(|validation| { + validation.schema_version == VALIDATION_ATTESTATION_SCHEMA_VERSION + && validation.policy_version == PUBLICATION_VALIDATION_POLICY_VERSION + && validation.revision == draft.revision + && validation.inventory_hash == report.inventory_hash + }) +} + +/// Require publication validation authority for the exact current draft bytes. +fn require_current_validation(status: &DraftStatus) -> Result<(), StudioError> { + if validation_matches(&status.draft, &status.publication) { + Ok(()) + } else { + Err(StudioError::ValidationNotCurrent) + } +} + /// Return whether review metadata binds to the exact fresh inventory. fn review_matches(draft: &Draft, report: &PublicationReport) -> bool { - report.valid + validation_matches(draft, report) && draft.review.as_ref().is_some_and(|review| { review.revision == draft.revision && review.inventory_hash == report.inventory_hash @@ -1618,6 +1704,18 @@ mod tests { } } + /// Persist publication authority for one valid no-bundle test draft. + async fn validate_for_publication(studio: &Studio, id: &str) -> DraftValidationReport { + studio + .validate_draft( + id, + MIN_PUBLICATION_CONFORMANCE_THRESHOLD, + &frameshift_conformance::MockRunner::new("unused"), + ) + .await + .unwrap() + } + /// Write one valid pack with a single built-in conformance test. fn write_conformance_pack( root: &Path, @@ -1817,6 +1915,15 @@ mod tests { let serialized = serde_json::to_string(&report).unwrap(); assert!(!serialized.contains("private response")); assert!(!serialized.contains(temporary.path().to_string_lossy().as_ref())); + let metadata = fs::read_to_string( + temporary + .path() + .join("studio/draft") + .join(METADATA_FILENAME), + ) + .unwrap(); + assert!(!metadata.contains("private response")); + assert!(!metadata.contains(temporary.path().to_string_lossy().as_ref())); } /// Packs without a conformance contract remain explicit and non-blocking. @@ -1848,6 +1955,50 @@ mod tests { ); } + /// Review entry points reject valid bytes until exact publication validation is current. + #[tokio::test] + async fn review_requires_current_publication_validation() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + write_valid_pack(&source); + let studio = Studio::open(temporary.path().join("studio")).unwrap(); + let imported = studio.import("draft", "Draft", &source).unwrap(); + let binding = review_binding(&imported.publication); + + assert!(matches!( + studio.snapshot_for_review("draft", &imported.publication.inventory_hash), + Err(StudioError::ValidationNotCurrent) + )); + assert!(matches!( + studio.review_report("draft", binding), + Err(StudioError::ValidationNotCurrent) + )); + assert!(matches!( + studio.confirm_review("draft", binding), + Err(StudioError::ValidationNotCurrent) + )); + + let low_threshold = studio + .validate_draft( + "draft", + MIN_PUBLICATION_CONFORMANCE_THRESHOLD - 0.1, + &frameshift_conformance::MockRunner::new("unused"), + ) + .await + .unwrap(); + assert!(low_threshold.valid); + assert!(studio.status("draft").unwrap().draft.validation.is_none()); + assert!(matches!( + studio.confirm_review("draft", binding), + Err(StudioError::ValidationNotCurrent) + )); + + let authorized = validate_for_publication(&studio, "draft").await; + assert!(authorized.valid); + assert!(studio.status("draft").unwrap().draft.validation.is_some()); + assert!(studio.review_report("draft", binding).is_ok()); + } + /// Bundle identity must match the exact pack manifest identity. #[tokio::test] async fn validation_blocks_bundle_identity_mismatch() { @@ -1899,6 +2050,45 @@ mod tests { .any(|finding| finding.code == "conformance.baseline_score_unmet")); } + /// A later failed validation revokes prior validation, review, and submission authority. + #[tokio::test] + async fn failed_validation_clears_prior_publication_authority() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + write_conformance_pack(&source, "test", "0.1.0", "substring", None); + let studio = Studio::open(temporary.path().join("studio")).unwrap(); + let imported = studio.import("draft", "Draft", &source).unwrap(); + let binding = review_binding(&imported.publication); + + let passing = studio + .validate_draft( + "draft", + MIN_PUBLICATION_CONFORMANCE_THRESHOLD, + &frameshift_conformance::MockRunner::new("hello"), + ) + .await + .unwrap(); + assert!(passing.valid); + studio.confirm_review("draft", binding).unwrap(); + studio.confirm_submission_intent("draft", binding).unwrap(); + + let failing = studio + .validate_draft( + "draft", + MIN_PUBLICATION_CONFORMANCE_THRESHOLD, + &frameshift_conformance::MockRunner::new("goodbye"), + ) + .await + .unwrap(); + assert!(!failing.valid); + let status = studio.status("draft").unwrap(); + assert!(status.draft.validation.is_none()); + assert!(status.draft.review.is_none()); + assert!(status.draft.submission_intent.is_none()); + assert!(!status.review_current); + assert!(!status.submission_intent_current); + } + /// Caller scorers fail closed when no explicit scoring implementation exists. #[tokio::test] async fn validation_rejects_implicit_caller_scorer() { @@ -2111,8 +2301,8 @@ mod tests { } /// Create, reload, review, and submit a valid draft across store instances. - #[test] - fn lifecycle_persists_across_restarts() { + #[tokio::test] + async fn lifecycle_persists_across_restarts() { let temporary = tempfile::tempdir().unwrap(); let studio = Studio::open(temporary.path().join("studio")).unwrap(); studio.create("test-draft", "Test draft").unwrap(); @@ -2131,6 +2321,7 @@ mod tests { .unwrap(); let status = studio.status("test-draft").unwrap(); let binding = review_binding(&status.publication); + validate_for_publication(&studio, "test-draft").await; let reviewed = studio.confirm_review("test-draft", binding).unwrap(); assert!(reviewed.review_current); let intended = studio @@ -2146,8 +2337,8 @@ mod tests { } /// Final review data exposes the full public manifest and exact artifact without local paths. - #[test] - fn review_report_exposes_path_free_exact_artifact_details() { + #[tokio::test] + async fn review_report_exposes_path_free_exact_artifact_details() { let temporary = tempfile::tempdir().unwrap(); let source = temporary.path().join("source"); write_valid_pack(&source); @@ -2165,6 +2356,7 @@ mod tests { let studio = Studio::open(&studio_root).unwrap(); let imported = studio.import("draft", "Draft", &source).unwrap(); let binding = review_binding(&imported.publication); + validate_for_publication(&studio, "draft").await; let snapshot = studio .snapshot_for_review("draft", &imported.publication.inventory_hash) .unwrap(); @@ -2192,14 +2384,15 @@ mod tests { } /// Submission intent and snapshots reject any artifact or publisher substitution. - #[test] - fn reviewed_binding_rejects_artifact_and_publisher_substitution() { + #[tokio::test] + async fn reviewed_binding_rejects_artifact_and_publisher_substitution() { let temporary = tempfile::tempdir().unwrap(); let source = temporary.path().join("source"); write_valid_pack(&source); let studio = Studio::open(temporary.path().join("studio")).unwrap(); let imported = studio.import("draft", "Draft", &source).unwrap(); let binding = review_binding(&imported.publication); + validate_for_publication(&studio, "draft").await; studio.confirm_review("draft", binding).unwrap(); let mut changed_archive = binding; @@ -2231,8 +2424,8 @@ mod tests { } /// Legacy inventory-only confirmations load safely but never count as current authority. - #[test] - fn legacy_inventory_only_confirmation_is_stale() { + #[tokio::test] + async fn legacy_inventory_only_confirmation_is_stale() { let temporary = tempfile::tempdir().unwrap(); let source = temporary.path().join("source"); write_valid_pack(&source); @@ -2240,12 +2433,14 @@ mod tests { let studio = Studio::open(&studio_root).unwrap(); let imported = studio.import("draft", "Draft", &source).unwrap(); let binding = review_binding(&imported.publication); + validate_for_publication(&studio, "draft").await; studio.confirm_review("draft", binding).unwrap(); studio.confirm_submission_intent("draft", binding).unwrap(); let metadata = studio_root.join("draft").join(METADATA_FILENAME); let mut persisted: serde_json::Value = serde_json::from_slice(&fs::read(&metadata).unwrap()).unwrap(); + persisted.as_object_mut().unwrap().remove("validation"); persisted["review"] .as_object_mut() .unwrap() @@ -2288,8 +2483,8 @@ mod tests { } /// Snapshotting requires current intent and returns only the reviewed public bytes. - #[test] - fn snapshot_requires_current_intent_and_freezes_public_bytes() { + #[tokio::test] + async fn snapshot_requires_current_intent_and_freezes_public_bytes() { let temporary = tempfile::tempdir().unwrap(); let source = temporary.path().join("source"); write_valid_pack(&source); @@ -2302,7 +2497,12 @@ mod tests { Ok(_) => panic!("unreviewed draft must not freeze"), Err(error) => error, }; - assert!(matches!(error, StudioError::SubmissionIntentNotCurrent)); + assert!(matches!(error, StudioError::ValidationNotCurrent)); + validate_for_publication(&studio, "draft").await; + assert!(matches!( + studio.snapshot_for_submission("draft", binding), + Err(StudioError::SubmissionIntentNotCurrent) + )); studio.confirm_review("draft", binding).unwrap(); studio.confirm_submission_intent("draft", binding).unwrap(); @@ -2426,14 +2626,15 @@ mod tests { } /// Any content mutation clears review and submission intent before saving. - #[test] - fn edit_invalidates_review_and_submission_intent() { + #[tokio::test] + async fn edit_invalidates_review_and_submission_intent() { let temporary = tempfile::tempdir().unwrap(); let source = temporary.path().join("source"); write_valid_pack(&source); let studio = Studio::open(temporary.path().join("studio")).unwrap(); let imported = studio.import("draft", "Draft", &source).unwrap(); let binding = review_binding(&imported.publication); + validate_for_publication(&studio, "draft").await; studio.confirm_review("draft", binding).unwrap(); studio.confirm_submission_intent("draft", binding).unwrap(); @@ -2442,6 +2643,7 @@ mod tests { .unwrap(); assert!(!status.review_current); assert!(!status.submission_intent_current); + assert!(status.draft.validation.is_none()); assert!(status.draft.review.is_none()); assert!(status.draft.submission_intent.is_none()); } diff --git a/docs/agent-forge/work/close-the-remaining-first-party-authentication-production-ga/slices/001-auth-catalog-schema.md b/docs/agent-forge/work/close-the-remaining-first-party-authentication-production-ga/slices/001-auth-catalog-schema.md new file mode 100644 index 0000000..8fecb10 --- /dev/null +++ b/docs/agent-forge/work/close-the-remaining-first-party-authentication-production-ga/slices/001-auth-catalog-schema.md @@ -0,0 +1,20 @@ +# Slice 001: auth-catalog-schema + +- **spec:** `spec_c1cbb2ec` + +## Components + +- auth catalog migration + +## Hard-won conditions + +- No raw secret columns +- Refresh history append-only +- Audit rows immutable +- Down migration refuses destructive rollback + +## Decision: Opaque access and rotating refresh family plus TOTP and browser-brokered PKCE + +- **why:** Add short-lived digest-only access tokens, a separate refresh-token history/family with atomic rotation and replay revocation, encrypted TOTP plus one-time recovery codes, durable sanitized audit rows, pre-work admission limiters, and one-time browser authorization codes bound to S256 PKCE and IP-literal loopback redirects. +- **alternative:** Stored-procedure state machine -- rejected: Business contracts hidden from Rust types; Harder test/refactor surface; More SQL procedural complexity +- **trust:** not independently verified diff --git a/docs/agent-forge/work/implement-the-ga-first-party-authentication-server-lane-for/slices/001-implement-first-party-browser-and-native-authentication-serv.md b/docs/agent-forge/work/implement-the-ga-first-party-authentication-server-lane-for/slices/001-implement-first-party-browser-and-native-authentication-serv.md new file mode 100644 index 0000000..82a23e3 --- /dev/null +++ b/docs/agent-forge/work/implement-the-ga-first-party-authentication-server-lane-for/slices/001-implement-first-party-browser-and-native-authentication-serv.md @@ -0,0 +1,24 @@ +# Slice 001: Implement first-party browser and native authentication server flows with rotating sessions, MFA, strict browser origin checks, and exact PKCE binding. + +- **spec:** `spec_3b5fc63e` + +## Components + +- first-party auth configuration and token primitives +- browser registration, login, refresh, logout +- MFA enrollment, activation, challenge, disable +- native browser authorization-code broker +- authentication middleware and protected route gating +- catalog test double and integration tests + +## Hard-won conditions + +- focused stale MFA replacement test passes +- focused browser refresh replay test passes +- focused native PKCE single-use test passes + +## Decision: Extend existing local-auth boundary + +- **why:** Keep cryptographic validation, DTOs, cookies, and route orchestration in local_auth/account middleware, delegating every state transition and transaction-bound success audit to CatalogBackend. +- **alternative:** Create a new auth service abstraction -- rejected: Duplicates catalog abstractions; More declarations and wiring; Higher merge/conflict risk under concurrent contract edits +- **trust:** not independently verified diff --git a/docs/wiki/Accounts-and-Publisher-Identity.md b/docs/wiki/Accounts-and-Publisher-Identity.md index feb1458..49a7ced 100644 --- a/docs/wiki/Accounts-and-Publisher-Identity.md +++ b/docs/wiki/Accounts-and-Publisher-Identity.md @@ -35,6 +35,36 @@ writes. Desktop and CLI clients request an explicit bearer session. The registry stores only SHA-256 token digests, so it cannot recover an issued invite or session token from the database. +## Password policy + +New first-party passwords must contain at least 15 Unicode scalar values and +at most 1,024 UTF-8 bytes. FrameShift does not impose character-class or +composition rules. It preserves the accepted password bytes exactly for +Argon2id hashing. + +Password creation also rejects known compromised values and expected +FrameShift-specific variants. Comparison ignores outer whitespace and ASCII +case, but that normalization is used only for the blocklist lookup. Login does +not apply creation policy, so a credential created under an older policy can +still be verified and migrated safely. + +The embedded baseline comes from [SecLists commit +`e5e49caa6fb648476f3bca391b26a45a4f5d3f13`](https://github.com/danielmiessler/SecLists/blob/e5e49caa6fb648476f3bca391b26a45a4f5d3f13/Passwords/Common-Credentials/xato-net-10-million-passwords-100.txt), file +`Passwords/Common-Credentials/xato-net-10-million-passwords-100.txt`. The +source file SHA-256 is +`3b9909eacc7322317399992a2d308b04be3ab903f06bfc935fc4c5796235531e`. +FrameShift stores only sorted SHA-256 digests of lowercase comparison values +in `crates/frameshift-server/src/password_blocklist.txt`. Reviewable +FrameShift-specific values remain explicit in `password_blocklist.rs`. + +To update the baseline, pin a reviewed SecLists commit, fetch that exact file, +verify and record its SHA-256, remove blank lines, lowercase ASCII, append the +reviewed FrameShift-specific variants in the Rust source when needed, hash each +source-list value without a trailing newline, then sort and deduplicate the +digest lines. Update the commit, source hash, and provenance here in the same +change. Run the server password-policy tests and review both blocklist diffs +before committing. + ## Sign in securely ```bash