From 91cfc536c62094ab6cef82aef426f75ee08d7e92 Mon Sep 17 00:00:00 2001 From: lewis Date: Tue, 10 Feb 2026 17:18:40 +0200 Subject: [PATCH] fix: bulk type safety improvements, added a couple of tests --- .config/nextest.toml | 8 + Cargo.lock | 29 +- Cargo.toml | 2 +- TODO.md | 36 +- crates/tranquil-auth/src/lib.rs | 20 +- crates/tranquil-auth/src/token.rs | 70 +-- crates/tranquil-auth/src/totp.rs | 38 +- crates/tranquil-auth/src/types.rs | 207 ++++++- crates/tranquil-auth/src/verify.rs | 100 +-- crates/tranquil-cache/src/lib.rs | 6 +- crates/tranquil-db-traits/src/backlink.rs | 49 +- .../src/channel_verification.rs | 11 +- crates/tranquil-db-traits/src/error.rs | 3 + crates/tranquil-db-traits/src/infra.rs | 70 ++- crates/tranquil-db-traits/src/lib.rs | 4 +- crates/tranquil-db-traits/src/repo.rs | 7 + crates/tranquil-db-traits/src/session.rs | 32 +- crates/tranquil-db-traits/src/sso.rs | 43 +- crates/tranquil-db-traits/src/user.rs | 21 +- crates/tranquil-db/src/postgres/infra.rs | 8 +- crates/tranquil-db/src/postgres/session.rs | 14 +- crates/tranquil-db/src/postgres/sso.rs | 63 +- crates/tranquil-db/src/postgres/user.rs | 28 +- .../tranquil-pds/src/api/actor/preferences.rs | 2 +- .../src/api/admin/account/delete.rs | 5 +- .../src/api/admin/account/email.rs | 4 +- .../src/api/admin/account/search.rs | 5 +- .../src/api/admin/account/update.rs | 12 +- crates/tranquil-pds/src/api/admin/config.rs | 27 +- crates/tranquil-pds/src/api/admin/invite.rs | 2 +- crates/tranquil-pds/src/api/admin/status.rs | 10 +- crates/tranquil-pds/src/api/age_assurance.rs | 15 +- crates/tranquil-pds/src/api/backup.rs | 11 +- crates/tranquil-pds/src/api/delegation.rs | 23 +- .../tranquil-pds/src/api/discord_webhook.rs | 2 +- crates/tranquil-pds/src/api/error.rs | 81 +-- .../tranquil-pds/src/api/identity/account.rs | 111 ++-- crates/tranquil-pds/src/api/identity/did.rs | 152 +++-- .../tranquil-pds/src/api/identity/handle.rs | 12 +- .../src/api/identity/plc/request.rs | 2 +- .../tranquil-pds/src/api/identity/plc/sign.rs | 6 +- .../src/api/identity/plc/submit.rs | 19 +- crates/tranquil-pds/src/api/mod.rs | 2 +- crates/tranquil-pds/src/api/moderation/mod.rs | 74 ++- .../src/api/notification_prefs.rs | 207 +++---- crates/tranquil-pds/src/api/proxy.rs | 203 +++--- crates/tranquil-pds/src/api/proxy_client.rs | 100 ++- crates/tranquil-pds/src/api/repo/blob.rs | 21 +- crates/tranquil-pds/src/api/repo/import.rs | 33 +- .../tranquil-pds/src/api/repo/record/batch.rs | 21 +- .../src/api/repo/record/delete.rs | 35 +- .../tranquil-pds/src/api/repo/record/read.rs | 2 +- .../tranquil-pds/src/api/repo/record/utils.rs | 175 ++++-- .../tranquil-pds/src/api/repo/record/write.rs | 27 +- .../src/api/server/account_status.rs | 25 +- .../src/api/server/app_password.rs | 7 +- crates/tranquil-pds/src/api/server/email.rs | 66 +- crates/tranquil-pds/src/api/server/invite.rs | 2 +- crates/tranquil-pds/src/api/server/logo.rs | 7 +- crates/tranquil-pds/src/api/server/meta.rs | 15 +- .../tranquil-pds/src/api/server/migration.rs | 4 +- .../src/api/server/passkey_account.rs | 58 +- .../tranquil-pds/src/api/server/passkeys.rs | 7 +- .../tranquil-pds/src/api/server/password.rs | 2 +- crates/tranquil-pds/src/api/server/reauth.rs | 47 +- .../src/api/server/service_auth.rs | 120 ++-- crates/tranquil-pds/src/api/server/session.rs | 90 ++- .../src/api/server/signing_key.rs | 16 +- .../src/api/server/trusted_devices.rs | 36 +- .../src/api/server/verify_token.rs | 94 ++- .../tranquil-pds/src/api/telegram_webhook.rs | 2 +- crates/tranquil-pds/src/api/temp.rs | 2 +- crates/tranquil-pds/src/api/validation.rs | 25 +- crates/tranquil-pds/src/api/verification.rs | 2 +- crates/tranquil-pds/src/appview/mod.rs | 72 ++- crates/tranquil-pds/src/auth/email_token.rs | 2 +- crates/tranquil-pds/src/auth/extractor.rs | 26 +- crates/tranquil-pds/src/auth/legacy_2fa.rs | 2 +- crates/tranquil-pds/src/auth/mod.rs | 138 +++-- crates/tranquil-pds/src/auth/scope_check.rs | 37 +- crates/tranquil-pds/src/auth/service.rs | 272 +++++--- .../src/auth/verification_token.rs | 132 ++-- crates/tranquil-pds/src/auth/webauthn.rs | 36 +- crates/tranquil-pds/src/cache_keys.rs | 35 ++ crates/tranquil-pds/src/circuit_breaker.rs | 54 +- crates/tranquil-pds/src/comms/mod.rs | 2 +- crates/tranquil-pds/src/comms/service.rs | 145 +---- crates/tranquil-pds/src/config.rs | 68 +- crates/tranquil-pds/src/delegation/scopes.rs | 12 + crates/tranquil-pds/src/image/mod.rs | 12 +- crates/tranquil-pds/src/lib.rs | 89 ++- .../src/oauth/endpoints/authorize.rs | 92 +-- .../tranquil-pds/src/oauth/endpoints/par.rs | 2 +- .../src/oauth/endpoints/token/grants.rs | 92 ++- .../src/oauth/endpoints/token/helpers.rs | 10 +- .../src/oauth/endpoints/token/mod.rs | 4 +- .../src/oauth/endpoints/token/types.rs | 117 ++-- crates/tranquil-pds/src/oauth/verify.rs | 32 +- crates/tranquil-pds/src/plc/mod.rs | 120 ++-- crates/tranquil-pds/src/rate_limit/mod.rs | 58 +- crates/tranquil-pds/src/scheduled.rs | 176 ++++-- crates/tranquil-pds/src/sso/config.rs | 50 +- crates/tranquil-pds/src/sso/endpoints.rs | 85 +-- crates/tranquil-pds/src/sso/providers.rs | 48 +- crates/tranquil-pds/src/state.rs | 114 +++- crates/tranquil-pds/src/sync/blob.rs | 66 +- crates/tranquil-pds/src/sync/car.rs | 61 +- crates/tranquil-pds/src/sync/commit.rs | 65 +- crates/tranquil-pds/src/sync/deprecated.rs | 79 +-- crates/tranquil-pds/src/sync/frame.rs | 49 +- crates/tranquil-pds/src/sync/import.rs | 9 +- crates/tranquil-pds/src/sync/mod.rs | 3 +- crates/tranquil-pds/src/sync/repo.rs | 143 ++--- .../tranquil-pds/src/sync/subscribe_repos.rs | 7 +- crates/tranquil-pds/src/sync/util.rs | 196 ++++-- crates/tranquil-pds/src/sync/verify.rs | 40 +- crates/tranquil-pds/src/sync/verify_tests.rs | 3 +- crates/tranquil-pds/src/types.rs | 6 + crates/tranquil-pds/src/util.rs | 49 +- crates/tranquil-pds/src/validation/mod.rs | 3 +- .../tests/account_notifications.rs | 12 +- crates/tranquil-pds/tests/commit_signing.rs | 4 +- crates/tranquil-pds/tests/common/mod.rs | 5 +- crates/tranquil-pds/tests/delete_account.rs | 6 +- crates/tranquil-pds/tests/firehose/mod.rs | 439 +++++++++++++ crates/tranquil-pds/tests/identity.rs | 4 +- crates/tranquil-pds/tests/jwt_security.rs | 53 +- crates/tranquil-pds/tests/plc_validation.rs | 10 +- crates/tranquil-pds/tests/repo_lifecycle.rs | 581 ++++++++++++++++++ crates/tranquil-pds/tests/ripple_cluster.rs | 13 + crates/tranquil-pds/tests/server.rs | 5 +- crates/tranquil-pds/tests/sso.rs | 58 +- crates/tranquil-ripple/src/transport.rs | 3 +- crates/tranquil-scopes/Cargo.toml | 1 + crates/tranquil-scopes/src/permission_set.rs | 94 ++- crates/tranquil-types/src/lib.rs | 58 +- .../components/dashboard/AdminContent.svelte | 162 ----- .../dashboard/InviteCodesContent.svelte | 40 +- frontend/src/lib/migration/plc-ops.ts | 176 +++++- frontend/src/locales/en.json | 15 +- frontend/src/locales/fi.json | 15 +- frontend/src/locales/ja.json | 15 +- frontend/src/locales/ko.json | 15 +- frontend/src/locales/sv.json | 15 +- frontend/src/locales/zh.json | 15 +- frontend/src/tests/migration/plc-ops.test.ts | 218 ++++++- 146 files changed, 5084 insertions(+), 2758 deletions(-) create mode 100644 crates/tranquil-pds/src/cache_keys.rs create mode 100644 crates/tranquil-pds/tests/firehose/mod.rs create mode 100644 crates/tranquil-pds/tests/repo_lifecycle.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index ed0b14f..02f9184 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -33,6 +33,10 @@ test-group = "heavy-load-tests" filter = "test(/two_node_stress_concurrent_load/)" test-group = "heavy-load-tests" +[[profile.default.overrides]] +filter = "binary(repo_lifecycle)" +test-group = "heavy-load-tests" + [[profile.ci.overrides]] filter = "test(/import_with_verification/) | test(/plc_migration/)" test-group = "serial-env-tests" @@ -48,3 +52,7 @@ test-group = "heavy-load-tests" [[profile.ci.overrides]] filter = "test(/two_node_stress_concurrent_load/)" test-group = "heavy-load-tests" + +[[profile.ci.overrides]] +filter = "binary(repo_lifecycle)" +test-group = "heavy-load-tests" diff --git a/Cargo.lock b/Cargo.lock index a6204b4..9097a32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "base32", @@ -5915,7 +5915,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -5928,7 +5928,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -5942,7 +5942,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -5958,7 +5958,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "chrono", @@ -5975,7 +5975,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -5991,7 +5991,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "bytes", @@ -6001,7 +6001,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "axum", @@ -6024,7 +6024,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes-gcm", "anyhow", @@ -6109,7 +6109,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.2.0" +version = "0.2.1" dependencies = [ "bytes", "cid", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "backon", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "futures", @@ -6153,6 +6153,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "thiserror 2.0.17", "tokio", "tracing", "urlencoding", @@ -6160,7 +6161,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "aws-config", @@ -6176,7 +6177,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "cid", diff --git a/Cargo.toml b/Cargo.toml index 458b16b..b53e1fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/TODO.md b/TODO.md index b6cffab..c9c0972 100644 --- a/TODO.md +++ b/TODO.md @@ -5,13 +5,6 @@ ### Storage backend abstraction Make storage layers swappable via traits. -filesystem blob storage -- [ ] FilesystemBlobStorage implementation -- [ ] directory structure (content-addressed like blobs/{cid} already used in objsto) -- [ ] atomic writes (write to temp, rename) -- [ ] config option to choose backend (env var or config flag) -- [ ] also traitify BackupStorage (currently hardcoded to objsto) - sqlite database backend - [ ] abstract db layer behind trait (queries, transactions, migrations) - [ ] sqlite implementation matching postgres behavior @@ -21,6 +14,8 @@ sqlite database backend - [ ] config option to choose backend (postgres vs sqlite) - [ ] document tradeoffs (sqlite for single-user/small, postgres for multi-user/scale) +- [ ] skip sqlite and just straight-up do our own db?! + ### Plugin system WASM component model plugins. Compile to wasm32-wasip2, sandboxed via wasmtime, capability-gated. Based on zed's extensions. @@ -131,30 +126,3 @@ plugin hooks (once core exists) - [ ] on_access_grant_request for custom authorization - [ ] on_key_rotation to notify interested parties ---- - -## Completed - -Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports. - -did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, clear registration warnings about did:web trade-offs vs did:plc. - -OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification. - -OAuth Scope Enforcement: Full granular scope system with consent UI, human-readable scope descriptions, per-client scope preferences, scope parsing (repo/blob/rpc/account/identity), endpoint-level scope checks, DPoP token support in auth extractors, token revocation on re-authorization, response_mode support (query/fragment). - -App endpoints: getPreferences, putPreferences, getProfile, getProfiles, getTimeline, getAuthorFeed, getActorLikes, getPostThread, getFeed, registerPush (all with local-first + proxy fallback). - -Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic transactions, valkey DID cache, debounced crawler notifications with circuit breakers, multi-channel notifications (email/Discord/Telegram/Signal), image processing, distributed rate limiting, security hardening. - -Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection. - -Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. - -Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods. - -App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. - -Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices. - -Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle). diff --git a/crates/tranquil-auth/src/lib.rs b/crates/tranquil-auth/src/lib.rs index cf1e75b..cbfe0c8 100644 --- a/crates/tranquil-auth/src/lib.rs +++ b/crates/tranquil-auth/src/lib.rs @@ -4,22 +4,22 @@ mod types; mod verify; pub use token::{ - SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, - TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, create_access_token_hs256, - create_access_token_hs256_with_metadata, create_access_token_with_delegation, - create_access_token_with_metadata, create_access_token_with_scope_metadata, - create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata, - create_refresh_token_with_metadata, create_service_token, create_service_token_hs256, + create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata, + create_access_token_with_delegation, create_access_token_with_metadata, + create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256, + create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata, + create_service_token, create_service_token_hs256, }; pub use totp::{ - decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, - generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, - verify_backup_code, verify_totp_code, + TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, + generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code, + is_backup_code_format, verify_backup_code, verify_totp_code, }; pub use types::{ - ActClaim, Claims, Header, TokenData, TokenVerifyError, TokenWithMetadata, UnsafeClaims, + ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType, + TokenVerifyError, TokenWithMetadata, UnsafeClaims, }; pub use verify::{ diff --git a/crates/tranquil-auth/src/token.rs b/crates/tranquil-auth/src/token.rs index 104ea9a..21cfb81 100644 --- a/crates/tranquil-auth/src/token.rs +++ b/crates/tranquil-auth/src/token.rs @@ -1,4 +1,6 @@ -use super::types::{ActClaim, Claims, Header, TokenWithMetadata}; +use super::types::{ + ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata, +}; use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -9,14 +11,6 @@ use sha2::Sha256; type HmacSha256 = Hmac; -pub const TOKEN_TYPE_ACCESS: &str = "at+jwt"; -pub const TOKEN_TYPE_REFRESH: &str = "refresh+jwt"; -pub const TOKEN_TYPE_SERVICE: &str = "jwt"; -pub const SCOPE_ACCESS: &str = "com.atproto.access"; -pub const SCOPE_REFRESH: &str = "com.atproto.refresh"; -pub const SCOPE_APP_PASS: &str = "com.atproto.appPass"; -pub const SCOPE_APP_PASS_PRIVILEGED: &str = "com.atproto.appPassPrivileged"; - pub fn create_access_token(did: &str, key_bytes: &[u8]) -> Result { Ok(create_access_token_with_metadata(did, key_bytes)?.token) } @@ -35,11 +29,11 @@ pub fn create_access_token_with_scope_metadata( scopes: Option<&str>, hostname: Option<&str>, ) -> Result { - let scope = scopes.unwrap_or(SCOPE_ACCESS); + let scope = scopes.unwrap_or(TokenScope::Access.as_str()); create_signed_token_with_metadata( did, scope, - TOKEN_TYPE_ACCESS, + TokenType::Access, key_bytes, Duration::minutes(15), hostname, @@ -53,12 +47,12 @@ pub fn create_access_token_with_delegation( controller_did: Option<&str>, hostname: Option<&str>, ) -> Result { - let scope = scopes.unwrap_or(SCOPE_ACCESS); + let scope = scopes.unwrap_or(TokenScope::Access.as_str()); let act = controller_did.map(|c| ActClaim { sub: c.to_string() }); create_signed_token_with_act( did, scope, - TOKEN_TYPE_ACCESS, + TokenType::Access, key_bytes, Duration::minutes(15), act, @@ -72,8 +66,8 @@ pub fn create_refresh_token_with_metadata( ) -> Result { create_signed_token_with_metadata( did, - SCOPE_REFRESH, - TOKEN_TYPE_REFRESH, + TokenScope::Refresh.as_str(), + TokenType::Refresh, key_bytes, Duration::days(14), None, @@ -92,8 +86,8 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) - iss: did.to_owned(), sub: did.to_owned(), aud: aud.to_owned(), - exp: expiration as usize, - iat: Utc::now().timestamp() as usize, + exp: expiration, + iat: Utc::now().timestamp(), scope: None, lxm: Some(lxm.to_string()), jti: uuid::Uuid::new_v4().to_string(), @@ -106,7 +100,7 @@ pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) - fn create_signed_token_with_metadata( did: &str, scope: &str, - typ: &str, + typ: TokenType, key_bytes: &[u8], duration: Duration, hostname: Option<&str>, @@ -117,7 +111,7 @@ fn create_signed_token_with_metadata( fn create_signed_token_with_act( did: &str, scope: &str, - typ: &str, + typ: TokenType, key_bytes: &[u8], duration: Duration, act: Option, @@ -140,8 +134,8 @@ fn create_signed_token_with_act( iss: did.to_owned(), sub: did.to_owned(), aud: format!("did:web:{}", aud_hostname), - exp: expiration as usize, - iat: Utc::now().timestamp() as usize, + exp: expiration, + iat: Utc::now().timestamp(), scope: Some(scope.to_string()), lxm: None, jti: jti.clone(), @@ -158,13 +152,13 @@ fn create_signed_token_with_act( } fn sign_claims(claims: Claims, key: &SigningKey) -> Result { - sign_claims_with_type(claims, key, TOKEN_TYPE_SERVICE) + sign_claims_with_type(claims, key, TokenType::Service) } -fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: &str) -> Result { +fn sign_claims_with_type(claims: Claims, key: &SigningKey, typ: TokenType) -> Result { let header = Header { - alg: "ES256K".to_string(), - typ: typ.to_string(), + alg: SigningAlgorithm::ES256K, + typ, }; let header_json = serde_json::to_string(&header)?; @@ -194,8 +188,8 @@ pub fn create_access_token_hs256_with_metadata( ) -> Result { create_hs256_token_with_metadata( did, - SCOPE_ACCESS, - TOKEN_TYPE_ACCESS, + TokenScope::Access.as_str(), + TokenType::Access, secret, Duration::minutes(15), ) @@ -207,8 +201,8 @@ pub fn create_refresh_token_hs256_with_metadata( ) -> Result { create_hs256_token_with_metadata( did, - SCOPE_REFRESH, - TOKEN_TYPE_REFRESH, + TokenScope::Refresh.as_str(), + TokenType::Refresh, secret, Duration::days(14), ) @@ -229,21 +223,21 @@ pub fn create_service_token_hs256( iss: did.to_owned(), sub: did.to_owned(), aud: aud.to_owned(), - exp: expiration as usize, - iat: Utc::now().timestamp() as usize, + exp: expiration, + iat: Utc::now().timestamp(), scope: None, lxm: Some(lxm.to_string()), jti: uuid::Uuid::new_v4().to_string(), act: None, }; - sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret) + sign_claims_hs256(claims, TokenType::Service, secret) } fn create_hs256_token_with_metadata( did: &str, scope: &str, - typ: &str, + typ: TokenType, secret: &[u8], duration: Duration, ) -> Result { @@ -261,8 +255,8 @@ fn create_hs256_token_with_metadata( "did:web:{}", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ), - exp: expiration as usize, - iat: Utc::now().timestamp() as usize, + exp: expiration, + iat: Utc::now().timestamp(), scope: Some(scope.to_string()), lxm: None, jti: jti.clone(), @@ -278,10 +272,10 @@ fn create_hs256_token_with_metadata( }) } -fn sign_claims_hs256(claims: Claims, typ: &str, secret: &[u8]) -> Result { +fn sign_claims_hs256(claims: Claims, typ: TokenType, secret: &[u8]) -> Result { let header = Header { - alg: "HS256".to_string(), - typ: typ.to_string(), + alg: SigningAlgorithm::HS256, + typ, }; let header_json = serde_json::to_string(&header)?; diff --git a/crates/tranquil-auth/src/totp.rs b/crates/tranquil-auth/src/totp.rs index 6f1e4c6..fe79cc1 100644 --- a/crates/tranquil-auth/src/totp.rs +++ b/crates/tranquil-auth/src/totp.rs @@ -1,12 +1,32 @@ use base32::Alphabet; -use rand::RngCore; +use rand::{Rng, RngCore}; use subtle::ConstantTimeEq; use totp_rs::{Algorithm, TOTP}; const TOTP_DIGITS: usize = 6; const TOTP_STEP: u64 = 30; +const TOTP_STEP_SIGNED: i64 = TOTP_STEP as i64; const TOTP_SECRET_LENGTH: usize = 20; +#[derive(Debug)] +pub enum TotpError { + CreationFailed(String), + QrGenerationFailed(String), + HashFailed(String), +} + +impl std::fmt::Display for TotpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CreationFailed(e) => write!(f, "TOTP creation failed: {}", e), + Self::QrGenerationFailed(e) => write!(f, "QR generation failed: {}", e), + Self::HashFailed(e) => write!(f, "Hash failed: {}", e), + } + } +} + +impl std::error::Error for TotpError {} + pub fn generate_totp_secret() -> Vec { let mut secret = vec![0u8; TOTP_SECRET_LENGTH]; rand::thread_rng().fill_bytes(&mut secret); @@ -31,7 +51,7 @@ fn create_totp( secret: Vec, issuer: Option, account_name: String, -) -> Result { +) -> Result { TOTP::new( Algorithm::SHA1, TOTP_DIGITS, @@ -41,7 +61,7 @@ fn create_totp( issuer, account_name, ) - .map_err(|e| format!("Failed to create TOTP: {}", e)) + .map_err(|e| TotpError::CreationFailed(e.to_string())) } pub fn verify_totp_code(secret: &[u8], code: &str) -> bool { @@ -60,7 +80,7 @@ pub fn verify_totp_code(secret: &[u8], code: &str) -> bool { .unwrap_or(0); [-1i64, 0, 1].iter().any(|&offset| { - let time = (now as i64 + offset * TOTP_STEP as i64) as u64; + let time = now.wrapping_add_signed(offset * TOTP_STEP_SIGNED); let expected = totp.generate(time); let is_valid: bool = code.as_bytes().ct_eq(expected.as_bytes()).into(); is_valid @@ -84,7 +104,7 @@ pub fn generate_qr_png_base64( secret: &[u8], account_name: &str, issuer: &str, -) -> Result { +) -> Result { use base64::{Engine, engine::general_purpose::STANDARD}; let totp = create_totp( @@ -95,7 +115,7 @@ pub fn generate_qr_png_base64( let qr_png = totp .get_qr_png() - .map_err(|e| format!("Failed to generate QR code: {}", e))?; + .map_err(|e| TotpError::QrGenerationFailed(e.to_string()))?; Ok(STANDARD.encode(qr_png)) } @@ -112,7 +132,7 @@ pub fn generate_backup_codes() -> Vec { (0..BACKUP_CODE_COUNT).for_each(|_| { let code: String = (0..BACKUP_CODE_LENGTH) .map(|_| { - let idx = (rng.next_u32() as usize) % BACKUP_CODE_ALPHABET.len(); + let idx = rng.gen_range(0..BACKUP_CODE_ALPHABET.len()); BACKUP_CODE_ALPHABET[idx] as char }) .collect(); @@ -122,8 +142,8 @@ pub fn generate_backup_codes() -> Vec { codes } -pub fn hash_backup_code(code: &str) -> Result { - bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| format!("Failed to hash code: {}", e)) +pub fn hash_backup_code(code: &str) -> Result { + bcrypt::hash(code, BACKUP_CODE_BCRYPT_COST).map_err(|e| TotpError::HashFailed(e.to_string())) } pub fn verify_backup_code(code: &str, hash: &str) -> bool { diff --git a/crates/tranquil-auth/src/types.rs b/crates/tranquil-auth/src/types.rs index 089b59e..3327d13 100644 --- a/crates/tranquil-auth/src/types.rs +++ b/crates/tranquil-auth/src/types.rs @@ -1,6 +1,203 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de, ser}; use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenType { + Access, + Refresh, + Service, +} + +impl TokenType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Access => "at+jwt", + Self::Refresh => "refresh+jwt", + Self::Service => "jwt", + } + } +} + +impl fmt::Display for TokenType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for TokenType { + type Err = TokenTypeParseError; + + fn from_str(s: &str) -> Result { + match s { + "at+jwt" => Ok(Self::Access), + "refresh+jwt" => Ok(Self::Refresh), + "jwt" => Ok(Self::Service), + _ => Err(TokenTypeParseError(s.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub struct TokenTypeParseError(pub String); + +impl fmt::Display for TokenTypeParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown token type: {}", self.0) + } +} + +impl std::error::Error for TokenTypeParseError {} + +impl Serialize for TokenType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for TokenType { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningAlgorithm { + ES256K, + HS256, +} + +impl SigningAlgorithm { + pub fn as_str(&self) -> &'static str { + match self { + Self::ES256K => "ES256K", + Self::HS256 => "HS256", + } + } +} + +impl fmt::Display for SigningAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for SigningAlgorithm { + type Err = SigningAlgorithmParseError; + + fn from_str(s: &str) -> Result { + match s { + "ES256K" => Ok(Self::ES256K), + "HS256" => Ok(Self::HS256), + _ => Err(SigningAlgorithmParseError(s.to_string())), + } + } +} + +#[derive(Debug, Clone)] +pub struct SigningAlgorithmParseError(pub String); + +impl fmt::Display for SigningAlgorithmParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown signing algorithm: {}", self.0) + } +} + +impl std::error::Error for SigningAlgorithmParseError {} + +impl Serialize for SigningAlgorithm { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for SigningAlgorithm { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenScope { + Access, + Refresh, + AppPass, + AppPassPrivileged, + Custom(String), +} + +impl TokenScope { + pub fn as_str(&self) -> &str { + match self { + Self::Access => "com.atproto.access", + Self::Refresh => "com.atproto.refresh", + Self::AppPass => "com.atproto.appPass", + Self::AppPassPrivileged => "com.atproto.appPassPrivileged", + Self::Custom(s) => s, + } + } + + pub fn is_access_like(&self) -> bool { + matches!(self, Self::Access | Self::AppPass | Self::AppPassPrivileged) + } +} + +impl fmt::Display for TokenScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for TokenScope { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "com.atproto.access" => Self::Access, + "com.atproto.refresh" => Self::Refresh, + "com.atproto.appPass" => Self::AppPass, + "com.atproto.appPassPrivileged" => Self::AppPassPrivileged, + other => Self::Custom(other.to_string()), + }) + } +} + +impl Serialize for TokenScope { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for TokenScope { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap_or_else(|e| match e {})) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenDecodeError { + InvalidFormat, + Base64DecodeFailed, + JsonDecodeFailed, + MissingClaim, +} + +impl fmt::Display for TokenDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat => write!(f, "Invalid token format"), + Self::Base64DecodeFailed => write!(f, "Base64 decode failed"), + Self::JsonDecodeFailed => write!(f, "JSON decode failed"), + Self::MissingClaim => write!(f, "Missing required claim"), + } + } +} + +impl std::error::Error for TokenDecodeError {} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActClaim { @@ -12,8 +209,8 @@ pub struct Claims { pub iss: String, pub sub: String, pub aud: String, - pub exp: usize, - pub iat: usize, + pub exp: i64, + pub iat: i64, #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -25,8 +222,8 @@ pub struct Claims { #[derive(Debug, Serialize, Deserialize)] pub struct Header { - pub alg: String, - pub typ: String, + pub alg: SigningAlgorithm, + pub typ: TokenType, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/tranquil-auth/src/verify.rs b/crates/tranquil-auth/src/verify.rs index 775d664..71d09fc 100644 --- a/crates/tranquil-auth/src/verify.rs +++ b/crates/tranquil-auth/src/verify.rs @@ -1,8 +1,7 @@ -use super::token::{ - SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, - TOKEN_TYPE_REFRESH, +use super::types::{ + Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType, + TokenVerifyError, UnsafeClaims, }; -use super::types::{Claims, Header, TokenData, TokenVerifyError, UnsafeClaims}; use anyhow::{Context, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -14,54 +13,54 @@ use subtle::ConstantTimeEq; type HmacSha256 = Hmac; -pub fn get_did_from_token(token: &str) -> Result { +pub fn get_did_from_token(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { - return Err("Invalid token format".to_string()); + return Err(TokenDecodeError::InvalidFormat); } let payload_bytes = URL_SAFE_NO_PAD .decode(parts[1]) - .map_err(|e| format!("Base64 decode failed: {}", e))?; + .map_err(|_| TokenDecodeError::Base64DecodeFailed)?; let claims: UnsafeClaims = - serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; + serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?; Ok(claims.sub.unwrap_or(claims.iss)) } -pub fn get_jti_from_token(token: &str) -> Result { +pub fn get_jti_from_token(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { - return Err("Invalid token format".to_string()); + return Err(TokenDecodeError::InvalidFormat); } let payload_bytes = URL_SAFE_NO_PAD .decode(parts[1]) - .map_err(|e| format!("Base64 decode failed: {}", e))?; + .map_err(|_| TokenDecodeError::Base64DecodeFailed)?; let claims: serde_json::Value = - serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; + serde_json::from_slice(&payload_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?; claims .get("jti") .and_then(|j| j.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| "No jti claim in token".to_string()) + .ok_or(TokenDecodeError::MissingClaim) } -pub fn get_algorithm_from_token(token: &str) -> Result { +pub fn get_algorithm_from_token(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { - return Err("Invalid token format".to_string()); + return Err(TokenDecodeError::InvalidFormat); } let header_bytes = URL_SAFE_NO_PAD .decode(parts[0]) - .map_err(|e| format!("Base64 decode failed: {}", e))?; + .map_err(|_| TokenDecodeError::Base64DecodeFailed)?; let header: Header = - serde_json::from_slice(&header_bytes).map_err(|e| format!("JSON decode failed: {}", e))?; + serde_json::from_slice(&header_bytes).map_err(|_| TokenDecodeError::JsonDecodeFailed)?; Ok(header.alg) } @@ -74,8 +73,12 @@ pub fn verify_access_token(token: &str, key_bytes: &[u8]) -> Result Result Result Result, - allowed_scopes: Option<&[&str]>, + expected_typ: Option, + allowed_scopes: Option<&[TokenScope]>, ) -> Result> { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -160,13 +167,18 @@ fn verify_token_internal( let claims: Claims = serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?; - let now = Utc::now().timestamp() as usize; + let now = Utc::now().timestamp(); if claims.exp < now { return Err(anyhow!("Token expired")); } if let Some(scopes) = allowed_scopes { - let token_scope = claims.scope.as_deref().unwrap_or(""); + let token_scope: TokenScope = claims + .scope + .as_deref() + .unwrap_or("") + .parse() + .unwrap_or_else(|e| match e {}); if !scopes.contains(&token_scope) { return Err(anyhow!("Invalid token scope: {}", token_scope)); } @@ -178,8 +190,8 @@ fn verify_token_internal( fn verify_token_hs256_internal( token: &str, secret: &[u8], - expected_typ: Option<&str>, - allowed_scopes: Option<&[&str]>, + expected_typ: Option, + allowed_scopes: Option<&[TokenScope]>, ) -> Result> { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -197,7 +209,7 @@ fn verify_token_hs256_internal( let header: Header = serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?; - if header.alg != "HS256" { + if header.alg != SigningAlgorithm::HS256 { return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg)); } @@ -235,13 +247,18 @@ fn verify_token_hs256_internal( let claims: Claims = serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?; - let now = Utc::now().timestamp() as usize; + let now = Utc::now().timestamp(); if claims.exp < now { return Err(anyhow!("Token expired")); } if let Some(scopes) = allowed_scopes { - let token_scope = claims.scope.as_deref().unwrap_or(""); + let token_scope: TokenScope = claims + .scope + .as_deref() + .unwrap_or("") + .parse() + .unwrap_or_else(|e| match e {}); if !scopes.contains(&token_scope) { return Err(anyhow!("Invalid token scope: {}", token_scope)); } @@ -254,14 +271,14 @@ pub fn verify_access_token_typed( token: &str, key_bytes: &[u8], ) -> Result, TokenVerifyError> { - verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None) + verify_token_typed_internal(token, key_bytes, Some(TokenType::Access), None) } fn verify_token_typed_internal( token: &str, key_bytes: &[u8], - expected_typ: Option<&str>, - allowed_scopes: Option<&[&str]>, + expected_typ: Option, + allowed_scopes: Option<&[TokenScope]>, ) -> Result, TokenVerifyError> { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -315,13 +332,18 @@ fn verify_token_typed_internal( return Err(TokenVerifyError::Invalid); }; - let now = Utc::now().timestamp() as usize; + let now = Utc::now().timestamp(); if claims.exp < now { return Err(TokenVerifyError::Expired); } if let Some(scopes) = allowed_scopes { - let token_scope = claims.scope.as_deref().unwrap_or(""); + let token_scope: TokenScope = claims + .scope + .as_deref() + .unwrap_or("") + .parse() + .unwrap_or_else(|e| match e {}); if !scopes.contains(&token_scope) { return Err(TokenVerifyError::Invalid); } diff --git a/crates/tranquil-cache/src/lib.rs b/crates/tranquil-cache/src/lib.rs index 87ff83d..8fa57d5 100644 --- a/crates/tranquil-cache/src/lib.rs +++ b/crates/tranquil-cache/src/lib.rs @@ -48,7 +48,7 @@ mod valkey { .arg(key) .arg(value) .arg("PX") - .arg(ttl.as_millis().min(i64::MAX as u128) as i64) + .arg(i64::try_from(ttl.as_millis()).unwrap_or(i64::MAX)) .query_async::<()>(&mut conn) .await .map_err(|e| CacheError::Connection(e.to_string())) @@ -94,7 +94,7 @@ mod valkey { async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool { let mut conn = self.conn.clone(); let full_key = format!("rl:{}", key); - let window_secs = window_ms.div_ceil(1000).max(1) as i64; + let window_secs = i64::try_from(window_ms.div_ceil(1000).max(1)).unwrap_or(i64::MAX); let result: Result = redis::Script::new( r"local c = redis.call('INCR', KEYS[1]) if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end @@ -106,7 +106,7 @@ return c", .invoke_async(&mut conn) .await; match result { - Ok(count) => count <= limit as i64, + Ok(count) => count <= i64::from(limit), Err(e) => { tracing::warn!(error = %e, "redis rate limit script failed, allowing request"); true diff --git a/crates/tranquil-db-traits/src/backlink.rs b/crates/tranquil-db-traits/src/backlink.rs index 8268001..6e0cf36 100644 --- a/crates/tranquil-db-traits/src/backlink.rs +++ b/crates/tranquil-db-traits/src/backlink.rs @@ -1,13 +1,60 @@ +use std::fmt; +use std::str::FromStr; + use async_trait::async_trait; use tranquil_types::{AtUri, Nsid}; use uuid::Uuid; use crate::DbError; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BacklinkPath { + Subject, + SubjectUri, +} + +impl BacklinkPath { + pub fn as_str(&self) -> &'static str { + match self { + Self::Subject => "subject", + Self::SubjectUri => "subject.uri", + } + } +} + +impl fmt::Display for BacklinkPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone)] +pub struct BacklinkPathParseError(String); + +impl fmt::Display for BacklinkPathParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "unknown backlink path: {}", self.0) + } +} + +impl std::error::Error for BacklinkPathParseError {} + +impl FromStr for BacklinkPath { + type Err = BacklinkPathParseError; + + fn from_str(s: &str) -> Result { + match s { + "subject" => Ok(Self::Subject), + "subject.uri" => Ok(Self::SubjectUri), + _ => Err(BacklinkPathParseError(s.to_owned())), + } + } +} + #[derive(Debug, Clone)] pub struct Backlink { pub uri: AtUri, - pub path: String, + pub path: BacklinkPath, pub link_to: String, } diff --git a/crates/tranquil-db-traits/src/channel_verification.rs b/crates/tranquil-db-traits/src/channel_verification.rs index d671e89..660dc32 100644 --- a/crates/tranquil-db-traits/src/channel_verification.rs +++ b/crates/tranquil-db-traits/src/channel_verification.rs @@ -10,7 +10,7 @@ pub struct ChannelVerificationStatus { } impl ChannelVerificationStatus { - pub fn new(email: bool, discord: bool, telegram: bool, signal: bool) -> Self { + pub fn from_db_row(email: bool, discord: bool, telegram: bool, signal: bool) -> Self { Self { email, discord, @@ -19,6 +19,15 @@ impl ChannelVerificationStatus { } } + pub fn from_verified_channels(channels: &[CommsChannel]) -> Self { + Self { + email: channels.contains(&CommsChannel::Email), + discord: channels.contains(&CommsChannel::Discord), + telegram: channels.contains(&CommsChannel::Telegram), + signal: channels.contains(&CommsChannel::Signal), + } + } + pub fn has_any_verified(&self) -> bool { self.email || self.discord || self.telegram || self.signal } diff --git a/crates/tranquil-db-traits/src/error.rs b/crates/tranquil-db-traits/src/error.rs index 4bcc053..43add05 100644 --- a/crates/tranquil-db-traits/src/error.rs +++ b/crates/tranquil-db-traits/src/error.rs @@ -26,6 +26,9 @@ pub enum DbError { #[error("Resource busy, try again")] LockContention, + #[error("Corrupt data in column: {0}")] + CorruptData(&'static str), + #[error("Other database error: {0}")] Other(String), } diff --git a/crates/tranquil-db-traits/src/infra.rs b/crates/tranquil-db-traits/src/infra.rs index 86008e5..716e2e2 100644 --- a/crates/tranquil-db-traits/src/infra.rs +++ b/crates/tranquil-db-traits/src/infra.rs @@ -31,25 +31,16 @@ impl InviteCodeState { } } -impl From for InviteCodeState { - fn from(disabled: bool) -> Self { - if disabled { - Self::Disabled - } else { - Self::Active +impl InviteCodeState { + pub fn from_disabled_flag(disabled: bool) -> Self { + match disabled { + true => Self::Disabled, + false => Self::Active, } } -} -impl From> for InviteCodeState { - fn from(disabled: Option) -> Self { - Self::from(disabled.unwrap_or(false)) - } -} - -impl From for bool { - fn from(state: InviteCodeState) -> Self { - matches!(state, InviteCodeState::Disabled) + pub fn from_optional_disabled_flag(disabled: Option) -> Self { + Self::from_disabled_flag(disabled.unwrap_or(false)) } } @@ -63,6 +54,51 @@ pub enum CommsChannel { Signal, } +impl CommsChannel { + pub fn as_str(self) -> &'static str { + match self { + Self::Email => "email", + Self::Discord => "discord", + Self::Telegram => "telegram", + Self::Signal => "signal", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Email => "email", + Self::Discord => "Discord", + Self::Telegram => "Telegram", + Self::Signal => "Signal", + } + } +} + +impl std::str::FromStr for CommsChannel { + type Err = InvalidCommsChannel; + + fn from_str(s: &str) -> Result { + match s { + "email" => Ok(Self::Email), + "discord" => Ok(Self::Discord), + "telegram" => Ok(Self::Telegram), + "signal" => Ok(Self::Signal), + _ => Err(InvalidCommsChannel), + } + } +} + +#[derive(Debug, Clone)] +pub struct InvalidCommsChannel; + +impl std::fmt::Display for InvalidCommsChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid comms channel") + } +} + +impl std::error::Error for InvalidCommsChannel {} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "comms_type", rename_all = "snake_case")] pub enum CommsType { @@ -139,7 +175,7 @@ pub struct InviteCodeRow { impl InviteCodeRow { pub fn state(&self) -> InviteCodeState { - InviteCodeState::from(self.disabled) + InviteCodeState::from_optional_disabled_flag(self.disabled) } } diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index 3fce715..ade5be7 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -14,7 +14,7 @@ mod session; mod sso; mod user; -pub use backlink::{Backlink, BacklinkRepository}; +pub use backlink::{Backlink, BacklinkPath, BacklinkRepository}; pub use backup::{ BackupForDeletion, BackupRepository, BackupRow, BackupStorageInfo, BlobExportInfo, OldBackupInfo, UserBackupInfo, @@ -68,5 +68,5 @@ pub use user::{ UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, - VerifiedTotpRecord, + VerifiedTotpRecord, WebauthnChallengeType, }; diff --git a/crates/tranquil-db-traits/src/repo.rs b/crates/tranquil-db-traits/src/repo.rs index 4189631..d5f6f29 100644 --- a/crates/tranquil-db-traits/src/repo.rs +++ b/crates/tranquil-db-traits/src/repo.rs @@ -57,6 +57,13 @@ impl AccountStatus { } } + pub fn for_firehose_typed(&self) -> Option { + match self { + Self::Active => None, + other => Some(*other), + } + } + pub fn parse(s: &str) -> Option { match s.to_lowercase().as_str() { "active" => Some(Self::Active), diff --git a/crates/tranquil-db-traits/src/session.rs b/crates/tranquil-db-traits/src/session.rs index 035cba0..69e6af3 100644 --- a/crates/tranquil-db-traits/src/session.rs +++ b/crates/tranquil-db-traits/src/session.rs @@ -20,17 +20,12 @@ impl LoginType { pub fn is_modern(self) -> bool { matches!(self, Self::Modern) } -} -impl From for LoginType { - fn from(legacy: bool) -> Self { - if legacy { Self::Legacy } else { Self::Modern } - } -} - -impl From for bool { - fn from(lt: LoginType) -> Self { - matches!(lt, LoginType::Legacy) + pub fn from_legacy_flag(legacy: bool) -> Self { + match legacy { + true => Self::Legacy, + false => Self::Modern, + } } } @@ -45,24 +40,15 @@ impl AppPasswordPrivilege { pub fn is_privileged(self) -> bool { matches!(self, Self::Privileged) } -} -impl From for AppPasswordPrivilege { - fn from(privileged: bool) -> Self { - if privileged { - Self::Privileged - } else { - Self::Standard + pub fn from_privileged_flag(privileged: bool) -> Self { + match privileged { + true => Self::Privileged, + false => Self::Standard, } } } -impl From for bool { - fn from(p: AppPasswordPrivilege) -> Self { - matches!(p, AppPasswordPrivilege::Privileged) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SessionId(i32); diff --git a/crates/tranquil-db-traits/src/sso.rs b/crates/tranquil-db-traits/src/sso.rs index 6679a2d..5dc061f 100644 --- a/crates/tranquil-db-traits/src/sso.rs +++ b/crates/tranquil-db-traits/src/sso.rs @@ -113,6 +113,7 @@ impl From for String { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "sso_provider_type", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] pub enum SsoProviderType { Github, Discord, @@ -141,7 +142,7 @@ impl SsoAction { } pub fn parse(s: &str) -> Option { - match s.to_lowercase().as_str() { + match s { "login" => Some(Self::Login), "link" => Some(Self::Link), "register" => Some(Self::Register), @@ -156,6 +157,44 @@ impl std::fmt::Display for SsoAction { } } +impl std::str::FromStr for SsoProviderType { + type Err = InvalidSsoProviderType; + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or(InvalidSsoProviderType) + } +} + +#[derive(Debug, Clone)] +pub struct InvalidSsoProviderType; + +impl std::fmt::Display for InvalidSsoProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid SSO provider type") + } +} + +impl std::error::Error for InvalidSsoProviderType {} + +impl std::str::FromStr for SsoAction { + type Err = InvalidSsoAction; + + fn from_str(s: &str) -> Result { + Self::parse(s).ok_or(InvalidSsoAction) + } +} + +#[derive(Debug, Clone)] +pub struct InvalidSsoAction; + +impl std::fmt::Display for InvalidSsoAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("invalid SSO action") + } +} + +impl std::error::Error for InvalidSsoAction {} + impl SsoProviderType { pub fn as_str(&self) -> &'static str { match self { @@ -169,7 +208,7 @@ impl SsoProviderType { } pub fn parse(s: &str) -> Option { - match s.to_lowercase().as_str() { + match s { "github" => Some(Self::Github), "discord" => Some(Self::Discord), "google" => Some(Self::Google), diff --git a/crates/tranquil-db-traits/src/user.rs b/crates/tranquil-db-traits/src/user.rs index 7192673..906f988 100644 --- a/crates/tranquil-db-traits/src/user.rs +++ b/crates/tranquil-db-traits/src/user.rs @@ -6,6 +6,21 @@ use uuid::Uuid; use crate::{ChannelVerificationStatus, CommsChannel, DbError, SsoProviderType}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebauthnChallengeType { + Registration, + Authentication, +} + +impl WebauthnChallengeType { + pub fn as_str(self) -> &'static str { + match self { + Self::Registration => "registration", + Self::Authentication => "authentication", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "account_type", rename_all = "snake_case")] pub enum AccountType { @@ -325,20 +340,20 @@ pub trait UserRepository: Send + Sync { async fn save_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, state_json: &str, ) -> Result; async fn load_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, ) -> Result, DbError>; async fn delete_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, ) -> Result<(), DbError>; async fn get_totp_record(&self, did: &Did) -> Result, DbError>; diff --git a/crates/tranquil-db/src/postgres/infra.rs b/crates/tranquil-db/src/postgres/infra.rs index c79df0e..e5db8af 100644 --- a/crates/tranquil-db/src/postgres/infra.rs +++ b/crates/tranquil-db/src/postgres/infra.rs @@ -261,7 +261,7 @@ impl InfraRepository for PostgresInfraRepository { .map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - state: InviteCodeState::from(r.disabled), + state: InviteCodeState::from_optional_disabled_flag(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: None, @@ -438,7 +438,7 @@ impl InfraRepository for PostgresInfraRepository { .map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - state: InviteCodeState::from(r.disabled), + state: InviteCodeState::from_optional_disabled_flag(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), @@ -461,7 +461,7 @@ impl InfraRepository for PostgresInfraRepository { Ok(result.map(|r| InviteCodeInfo { code: r.code, available_uses: r.available_uses, - state: InviteCodeState::from(r.disabled), + state: InviteCodeState::from_optional_disabled_flag(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), @@ -492,7 +492,7 @@ impl InfraRepository for PostgresInfraRepository { InviteCodeInfo { code: r.code, available_uses: r.available_uses, - state: InviteCodeState::from(r.disabled), + state: InviteCodeState::from_optional_disabled_flag(r.disabled), for_account: Some(Did::from(r.for_account)), created_at: r.created_at, created_by: Some(Did::from(r.created_by)), diff --git a/crates/tranquil-db/src/postgres/session.rs b/crates/tranquil-db/src/postgres/session.rs index 3793f5f..0f1702c 100644 --- a/crates/tranquil-db/src/postgres/session.rs +++ b/crates/tranquil-db/src/postgres/session.rs @@ -37,7 +37,7 @@ impl SessionRepository for PostgresSessionRepository { data.refresh_jti, data.access_expires_at, data.refresh_expires_at, - bool::from(data.login_type), + data.login_type.is_legacy(), data.mfa_verified, data.scope, data.controller_did.as_ref().map(|d| d.as_str()), @@ -75,7 +75,7 @@ impl SessionRepository for PostgresSessionRepository { refresh_jti: r.refresh_jti, access_expires_at: r.access_expires_at, refresh_expires_at: r.refresh_expires_at, - login_type: LoginType::from(r.legacy_login), + login_type: LoginType::from_legacy_flag(r.legacy_login), mfa_verified: r.mfa_verified, scope: r.scope, controller_did: r.controller_did.map(Did::from), @@ -325,7 +325,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privilege: AppPasswordPrivilege::from(r.privileged), + privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), }) @@ -358,7 +358,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privilege: AppPasswordPrivilege::from(r.privileged), + privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), }) @@ -389,7 +389,7 @@ impl SessionRepository for PostgresSessionRepository { name: r.name, password_hash: r.password_hash, created_at: r.created_at, - privilege: AppPasswordPrivilege::from(r.privileged), + privilege: AppPasswordPrivilege::from_privileged_flag(r.privileged), scopes: r.scopes, created_by_controller_did: r.created_by_controller_did.map(Did::from), })) @@ -405,7 +405,7 @@ impl SessionRepository for PostgresSessionRepository { data.user_id, data.name, data.password_hash, - bool::from(data.privilege), + data.privilege.is_privileged(), data.scopes, data.created_by_controller_did.as_ref().map(|d| d.as_str()) ) @@ -486,7 +486,7 @@ impl SessionRepository for PostgresSessionRepository { .map_err(map_sqlx_error)?; Ok(row.map(|r| SessionMfaStatus { - login_type: LoginType::from(r.legacy_login), + login_type: LoginType::from_legacy_flag(r.legacy_login), mfa_verified: r.mfa_verified, last_reauth_at: r.last_reauth_at, })) diff --git a/crates/tranquil-db/src/postgres/sso.rs b/crates/tranquil-db/src/postgres/sso.rs index 9402fd3..1bf1060 100644 --- a/crates/tranquil-db/src/postgres/sso.rs +++ b/crates/tranquil-db/src/postgres/sso.rs @@ -68,17 +68,20 @@ impl SsoRepository for PostgresSsoRepository { .await .map_err(map_sqlx_error)?; - Ok(row.map(|r| ExternalIdentity { - id: r.id, - did: unsafe { Did::new_unchecked(&r.did) }, - provider: r.provider, - provider_user_id: ExternalUserId::from(r.provider_user_id), - provider_username: r.provider_username.map(ExternalUsername::from), - provider_email: r.provider_email.map(ExternalEmail::from), - created_at: r.created_at, - updated_at: r.updated_at, - last_login_at: r.last_login_at, - })) + row.map(|r| { + Ok(ExternalIdentity { + id: r.id, + did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?, + provider: r.provider, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), + created_at: r.created_at, + updated_at: r.updated_at, + last_login_at: r.last_login_at, + }) + }) + .transpose() } async fn get_external_identities_by_did( @@ -99,20 +102,21 @@ impl SsoRepository for PostgresSsoRepository { .await .map_err(map_sqlx_error)?; - Ok(rows - .into_iter() - .map(|r| ExternalIdentity { - id: r.id, - did: unsafe { Did::new_unchecked(&r.did) }, - provider: r.provider, - provider_user_id: ExternalUserId::from(r.provider_user_id), - provider_username: r.provider_username.map(ExternalUsername::from), - provider_email: r.provider_email.map(ExternalEmail::from), - created_at: r.created_at, - updated_at: r.updated_at, - last_login_at: r.last_login_at, + rows.into_iter() + .map(|r| { + Ok(ExternalIdentity { + id: r.id, + did: r.did.parse().map_err(|_| DbError::CorruptData("DID"))?, + provider: r.provider, + provider_user_id: ExternalUserId::from(r.provider_user_id), + provider_username: r.provider_username.map(ExternalUsername::from), + provider_email: r.provider_email.map(ExternalEmail::from), + created_at: r.created_at, + updated_at: r.updated_at, + last_login_at: r.last_login_at, + }) }) - .collect()) + .collect() } async fn update_external_identity_login( @@ -202,7 +206,10 @@ impl SsoRepository for PostgresSsoRepository { .map_err(map_sqlx_error)?; row.map(|r| { - let action = SsoAction::parse(&r.action).ok_or(DbError::NotFound)?; + let action: SsoAction = r + .action + .parse() + .map_err(|_| DbError::CorruptData("sso_action"))?; Ok(SsoAuthState { state: r.state, request_uri: r.request_uri, @@ -210,7 +217,11 @@ impl SsoRepository for PostgresSsoRepository { action, nonce: r.nonce, code_verifier: r.code_verifier, - did: r.did.map(|d| unsafe { Did::new_unchecked(&d) }), + did: r + .did + .map(|d| d.parse::()) + .transpose() + .map_err(|_| DbError::CorruptData("DID"))?, created_at: r.created_at, expires_at: r.expires_at, }) diff --git a/crates/tranquil-db/src/postgres/user.rs b/crates/tranquil-db/src/postgres/user.rs index ef33421..70db765 100644 --- a/crates/tranquil-db/src/postgres/user.rs +++ b/crates/tranquil-db/src/postgres/user.rs @@ -14,7 +14,7 @@ use tranquil_db_traits::{ UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, UserPasswordInfo, UserRepository, UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, - UserVerificationInfo, UserWithKey, + UserVerificationInfo, UserWithKey, WebauthnChallengeType, }; pub struct PostgresUserRepository { @@ -281,7 +281,7 @@ impl UserRepository for PostgresUserRepository { password_hash: r.password_hash, deactivated_at: r.deactivated_at, takedown_ref: r.takedown_ref, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( r.email_verified, r.discord_verified, r.telegram_verified, @@ -746,7 +746,7 @@ impl UserRepository for PostgresUserRepository { id: r.id, handle: Handle::from(r.handle), email: r.email, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( r.email_verified, r.discord_verified, r.telegram_verified, @@ -1029,7 +1029,7 @@ impl UserRepository for PostgresUserRepository { async fn save_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, state_json: &str, ) -> Result { let id = Uuid::new_v4(); @@ -1041,7 +1041,7 @@ impl UserRepository for PostgresUserRepository { id, did.as_str(), challenge, - challenge_type, + challenge_type.as_str(), state_json, expires_at, ) @@ -1055,14 +1055,14 @@ impl UserRepository for PostgresUserRepository { async fn load_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, ) -> Result, DbError> { let row = sqlx::query_scalar!( r#"SELECT state_json FROM webauthn_challenges WHERE did = $1 AND challenge_type = $2 AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1"#, did.as_str(), - challenge_type + challenge_type.as_str() ) .fetch_optional(&self.pool) .await @@ -1074,12 +1074,12 @@ impl UserRepository for PostgresUserRepository { async fn delete_webauthn_challenge( &self, did: &Did, - challenge_type: &str, + challenge_type: WebauthnChallengeType, ) -> Result<(), DbError> { sqlx::query!( "DELETE FROM webauthn_challenges WHERE did = $1 AND challenge_type = $2", did.as_str(), - challenge_type + challenge_type.as_str() ) .execute(&self.pool) .await @@ -1365,7 +1365,7 @@ impl UserRepository for PostgresUserRepository { preferred_comms_channel: row.preferred_comms_channel, deactivated_at: row.deactivated_at, takedown_ref: row.takedown_ref, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( row.email_verified, row.discord_verified, row.telegram_verified, @@ -1395,7 +1395,7 @@ impl UserRepository for PostgresUserRepository { id: row.id, two_factor_enabled: row.two_factor_enabled, preferred_comms_channel: row.preferred_comms_channel, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( row.email_verified, row.discord_verified, row.telegram_verified, @@ -1432,7 +1432,7 @@ impl UserRepository for PostgresUserRepository { takedown_ref: row.takedown_ref, preferred_locale: row.preferred_locale, preferred_comms_channel: row.preferred_comms_channel, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( row.email_verified, row.discord_verified, row.telegram_verified, @@ -1525,7 +1525,7 @@ impl UserRepository for PostgresUserRepository { email: row.email, deactivated_at: row.deactivated_at, takedown_ref: row.takedown_ref, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( row.email_verified, row.discord_verified, row.telegram_verified, @@ -1602,7 +1602,7 @@ impl UserRepository for PostgresUserRepository { discord_username: row.discord_username, telegram_username: row.telegram_username, signal_username: row.signal_username, - channel_verification: ChannelVerificationStatus::new( + channel_verification: ChannelVerificationStatus::from_db_row( row.email_verified, row.discord_verified, row.telegram_verified, diff --git a/crates/tranquil-pds/src/api/actor/preferences.rs b/crates/tranquil-pds/src/api/actor/preferences.rs index ef705b7..ba8f102 100644 --- a/crates/tranquil-pds/src/api/actor/preferences.rs +++ b/crates/tranquil-pds/src/api/actor/preferences.rs @@ -21,7 +21,7 @@ fn get_age_from_datestring(birth_date: &str) -> Option { let bday = NaiveDate::parse_from_str(birth_date, "%Y-%m-%d").ok()?; let today = Utc::now().date_naive(); let mut age = today.year() - bday.year(); - let m = today.month() as i32 - bday.month() as i32; + let m = i32::try_from(today.month()).unwrap_or(0) - i32::try_from(bday.month()).unwrap_or(0); if m < 0 || (m == 0 && today.day() < bday.day()) { age -= 1; } diff --git a/crates/tranquil-pds/src/api/admin/account/delete.rs b/crates/tranquil-pds/src/api/admin/account/delete.rs index 85d0ad8..75a48a4 100644 --- a/crates/tranquil-pds/src/api/admin/account/delete.rs +++ b/crates/tranquil-pds/src/api/admin/account/delete.rs @@ -48,6 +48,9 @@ pub async fn delete_account( did, e ); } - let _ = state.cache.delete(&format!("handle:{}", handle)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&handle)) + .await; Ok(EmptyResponse::ok().into_response()) } diff --git a/crates/tranquil-pds/src/api/admin/account/email.rs b/crates/tranquil-pds/src/api/admin/account/email.rs index 8670499..2f90ad5 100644 --- a/crates/tranquil-pds/src/api/admin/account/email.rs +++ b/crates/tranquil-pds/src/api/admin/account/email.rs @@ -1,4 +1,4 @@ -use crate::api::error::{ApiError, AtpJson, DbResultExt}; +use crate::api::error::{ApiError, DbResultExt}; use crate::auth::{Admin, Auth}; use crate::state::AppState; use crate::types::Did; @@ -30,7 +30,7 @@ pub struct SendEmailOutput { pub async fn send_email( State(state): State, _auth: Auth, - AtpJson(input): AtpJson, + Json(input): Json, ) -> Result { let content = input.content.trim(); if content.is_empty() { diff --git a/crates/tranquil-pds/src/api/admin/account/search.rs b/crates/tranquil-pds/src/api/admin/account/search.rs index 429cfde..1b2ed65 100644 --- a/crates/tranquil-pds/src/api/admin/account/search.rs +++ b/crates/tranquil-pds/src/api/admin/account/search.rs @@ -67,10 +67,11 @@ pub async fn search_accounts( .await .log_db_err("in search_accounts")?; - let has_more = rows.len() > limit as usize; + let limit_usize = usize::try_from(limit).unwrap_or(0); + let has_more = rows.len() > limit_usize; let accounts: Vec = rows .into_iter() - .take(limit as usize) + .take(limit_usize) .map(|row| AccountView { did: row.did.clone(), handle: row.handle, diff --git a/crates/tranquil-pds/src/api/admin/account/update.rs b/crates/tranquil-pds/src/api/admin/account/update.rs index 98b7335..5cd89dd 100644 --- a/crates/tranquil-pds/src/api/admin/account/update.rs +++ b/crates/tranquil-pds/src/api/admin/account/update.rs @@ -84,7 +84,7 @@ pub async fn update_account_handle( .ok() .flatten() .ok_or(ApiError::AccountNotFound)?; - let handle_for_check = unsafe { Handle::new_unchecked(&handle) }; + let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; if let Ok(true) = state .user_repo .check_handle_exists(&handle_for_check, user_id) @@ -100,9 +100,15 @@ pub async fn update_account_handle( Ok(0) => Err(ApiError::AccountNotFound), Ok(_) => { if let Some(old) = old_handle { - let _ = state.cache.delete(&format!("handle:{}", old)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&old)) + .await; } - let _ = state.cache.delete(&format!("handle:{}", handle)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&handle)) + .await; if let Err(e) = crate::api::repo::record::sequence_identity_event( &state, did, diff --git a/crates/tranquil-pds/src/api/admin/config.rs b/crates/tranquil-pds/src/api/admin/config.rs index 3a72d43..7f591ea 100644 --- a/crates/tranquil-pds/src/api/admin/config.rs +++ b/crates/tranquil-pds/src/api/admin/config.rs @@ -3,7 +3,7 @@ use crate::auth::{Admin, Auth}; use crate::state::AppState; use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; -use tracing::error; +use tracing::{error, warn}; use tranquil_types::CidLink; #[derive(Serialize)] @@ -187,15 +187,24 @@ pub async fn update_server_config( }; if let Some(old_cid_str) = should_delete_old { - let old_cid = unsafe { CidLink::new_unchecked(old_cid_str) }; - if let Ok(Some(storage_key)) = - state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await - { - if let Err(e) = state.blob_store.delete(&storage_key).await { - error!("Failed to delete old logo blob from storage: {:?}", e); + match CidLink::new(old_cid_str) { + Ok(old_cid) => { + if let Ok(Some(storage_key)) = + state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await + { + if let Err(e) = state.blob_store.delete(&storage_key).await { + error!("Failed to delete old logo blob from storage: {:?}", e); + } + if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await { + error!("Failed to delete old logo blob record: {:?}", e); + } + } } - if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await { - error!("Failed to delete old logo blob record: {:?}", e); + Err(e) => { + warn!( + "Old logo CID in database is invalid, skipping cleanup: {:?}", + e + ); } } } diff --git a/crates/tranquil-pds/src/api/admin/invite.rs b/crates/tranquil-pds/src/api/admin/invite.rs index e0c91e8..b3f3083 100644 --- a/crates/tranquil-pds/src/api/admin/invite.rs +++ b/crates/tranquil-pds/src/api/admin/invite.rs @@ -144,7 +144,7 @@ pub async fn get_invite_codes( }) .collect(); - let next_cursor = if codes_rows.len() == limit as usize { + let next_cursor = if codes_rows.len() == usize::try_from(limit).unwrap_or(0) { codes_rows.last().map(|r| r.code.clone()) } else { None diff --git a/crates/tranquil-pds/src/api/admin/status.rs b/crates/tranquil-pds/src/api/admin/status.rs index 52424c6..d61e331 100644 --- a/crates/tranquil-pds/src/api/admin/status.rs +++ b/crates/tranquil-pds/src/api/admin/status.rs @@ -175,7 +175,10 @@ pub async fn update_subject_status( Some("com.atproto.admin.defs#repoRef") => { let did_str = input.subject.get("did").and_then(|d| d.as_str()); if let Some(did_str) = did_str { - let did = unsafe { Did::new_unchecked(did_str) }; + let did: Did = match did_str.parse() { + Ok(d) => d, + Err(_) => return Err(ApiError::InvalidDid("Invalid DID format".into())), + }; if let Some(takedown) = &input.takedown { let takedown_ref = if takedown.applied { takedown.r#ref.as_deref() @@ -230,7 +233,10 @@ pub async fn update_subject_status( } } if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await { - let _ = state.cache.delete(&format!("handle:{}", handle)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&handle)) + .await; } return Ok(( StatusCode::OK, diff --git a/crates/tranquil-pds/src/api/age_assurance.rs b/crates/tranquil-pds/src/api/age_assurance.rs index 65a19a4..18946d5 100644 --- a/crates/tranquil-pds/src/api/age_assurance.rs +++ b/crates/tranquil-pds/src/api/age_assurance.rs @@ -1,9 +1,9 @@ -use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop}; +use crate::auth::{AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop}; use crate::state::AppState; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, }; use serde_json::json; @@ -33,25 +33,24 @@ pub async fn get_age_assurance_state() -> Response { } async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option { - let auth_header = crate::util::get_header_str(headers, "Authorization"); + let auth_header = crate::util::get_header_str(headers, http::header::AUTHORIZATION); tracing::debug!(?auth_header, "age assurance: extracting token"); let extracted = extract_auth_token_from_header(auth_header)?; tracing::debug!("age assurance: got token, validating"); - let dpop_proof = crate::util::get_header_str(headers, "DPoP"); + let dpop_proof = crate::util::get_header_str(headers, crate::util::HEADER_DPOP); let http_uri = "/"; let auth_user = match validate_token_with_dpop( state.user_repo.as_ref(), state.oauth_repo.as_ref(), &extracted.token, - extracted.is_dpop, + extracted.scheme, dpop_proof, - "GET", + Method::GET.as_str(), http_uri, - false, - false, + AccountRequirement::Active, ) .await { diff --git a/crates/tranquil-pds/src/api/backup.rs b/crates/tranquil-pds/src/api/backup.rs index 237888d..32df352 100644 --- a/crates/tranquil-pds/src/api/backup.rs +++ b/crates/tranquil-pds/src/api/backup.rs @@ -4,6 +4,7 @@ use crate::auth::{Active, Auth}; use crate::scheduled::generate_full_backup; use crate::state::AppState; use crate::storage::{BackupStorage, backup_retention_count}; +use anyhow::Context; use axum::{ Json, extract::{Query, State}, @@ -213,7 +214,7 @@ pub async fn create_backup( }; let block_count = crate::scheduled::count_car_blocks(&car_bytes); - let size_bytes = car_bytes.len() as i64; + let size_bytes = i64::try_from(car_bytes.len()).unwrap_or(i64::MAX); let storage_key = match backup_storage .put_backup(&user.did, &repo_rev, &car_bytes) @@ -292,11 +293,11 @@ async fn cleanup_old_backups( backup_storage: &dyn BackupStorage, user_id: uuid::Uuid, retention_count: u32, -) -> Result<(), String> { +) -> anyhow::Result<()> { let old_backups: Vec = backup_repo - .get_old_backups(user_id, retention_count as i64) + .get_old_backups(user_id, i64::from(retention_count)) .await - .map_err(|e| format!("DB error fetching old backups: {}", e))?; + .context("DB error fetching old backups")?; for backup in old_backups { if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { @@ -311,7 +312,7 @@ async fn cleanup_old_backups( backup_repo .delete_backup(backup.id) .await - .map_err(|e| format!("Failed to delete old backup record: {}", e))?; + .context("Failed to delete old backup record")?; } Ok(()) diff --git a/crates/tranquil-pds/src/api/delegation.rs b/crates/tranquil-pds/src/api/delegation.rs index b4487aa..580c68f 100644 --- a/crates/tranquil-pds/src/api/delegation.rs +++ b/crates/tranquil-pds/src/api/delegation.rs @@ -7,7 +7,7 @@ use crate::delegation::{ }; use crate::rate_limit::{AccountCreationLimit, RateLimited}; use crate::state::AppState; -use crate::types::{Did, Handle, Nsid, Rkey}; +use crate::types::{Did, Handle}; use crate::util::{pds_hostname, pds_hostname_without_port}; use axum::{ Json, @@ -164,7 +164,9 @@ pub async fn remove_controller( .session_repo .delete_app_passwords_by_controller(&auth.did, &input.controller_did) .await - .unwrap_or(0) as usize; + .unwrap_or(0) + .try_into() + .unwrap_or(0usize); let revoked_oauth_tokens = state .oauth_repo @@ -473,9 +475,7 @@ pub async fn create_delegated_account( Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()), } } else { - let invite_required = std::env::var("INVITE_CODE_REQUIRED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); if invite_required { return Ok(ApiError::InviteCodeRequired.into_response()); } @@ -529,8 +529,11 @@ pub async fn create_delegated_account( .into_response()); } - let did = unsafe { Did::new_unchecked(&genesis_result.did) }; - let handle = unsafe { Handle::new_unchecked(&handle) }; + let did: Did = genesis_result + .did + .parse() + .map_err(|_| ApiError::InternalError(Some("PLC genesis returned invalid DID".into())))?; + let handle: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account"); let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { @@ -627,13 +630,11 @@ pub async fn create_delegated_account( "$type": "app.bsky.actor.profile", "displayName": handle }); - let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; - let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did, - &profile_collection, - &profile_rkey, + &crate::types::PROFILE_COLLECTION, + &crate::types::PROFILE_RKEY, &profile_record, ) .await diff --git a/crates/tranquil-pds/src/api/discord_webhook.rs b/crates/tranquil-pds/src/api/discord_webhook.rs index bf945c8..90bd6a9 100644 --- a/crates/tranquil-pds/src/api/discord_webhook.rs +++ b/crates/tranquil-pds/src/api/discord_webhook.rs @@ -183,7 +183,7 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response { state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, - "discord", + tranquil_db_traits::CommsChannel::Discord, &discord_user_id, pds_hostname(), ) diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index e1e19ba..18fec81 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -1,10 +1,9 @@ use axum::{ Json, - extract::{FromRequest, Request, rejection::JsonRejection}, - http::StatusCode, + http::{HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; -use serde::{Serialize, de::DeserializeOwned}; +use serde::Serialize; use std::borrow::Cow; #[derive(Debug, Serialize)] @@ -103,7 +102,7 @@ pub enum ApiError { UpstreamTimeout, UpstreamUnavailable(String), UpstreamError { - status: u16, + status: StatusCode, error: Option, message: Option, }, @@ -127,9 +126,7 @@ impl ApiError { } Self::ServiceUnavailable(_) | Self::BackupsDisabled => StatusCode::SERVICE_UNAVAILABLE, Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT, - Self::UpstreamError { status, .. } => { - StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY) - } + Self::UpstreamError { status, .. } => *status, Self::AuthenticationRequired | Self::AuthenticationFailed(_) | Self::AccountDeactivated @@ -451,7 +448,7 @@ impl ApiError { _ => None, } } - pub fn from_upstream_response(status: u16, body: &[u8]) -> Self { + pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self { if let Ok(parsed) = serde_json::from_slice::(body) { let error = parsed .get("error") @@ -485,18 +482,18 @@ impl IntoResponse for ApiError { match &self { Self::ExpiredToken(_) => { response.headers_mut().insert( - "WWW-Authenticate", - "Bearer error=\"invalid_token\", error_description=\"Token has expired\"" - .parse() - .unwrap(), + http::header::WWW_AUTHENTICATE, + HeaderValue::from_static( + "Bearer error=\"invalid_token\", error_description=\"Token has expired\"", + ), ); } Self::OAuthExpiredToken(_) => { response.headers_mut().insert( - "WWW-Authenticate", - "DPoP error=\"invalid_token\", error_description=\"Token has expired\"" - .parse() - .unwrap(), + http::header::WWW_AUTHENTICATE, + HeaderValue::from_static( + "DPoP error=\"invalid_token\", error_description=\"Token has expired\"", + ), ); } _ => {} @@ -723,58 +720,6 @@ pub fn parse_did_option(s: Option<&str>) -> Result, s.map(parse_did).transpose() } -pub struct AtpJson(pub T); - -impl FromRequest for AtpJson -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = (StatusCode, Json); - - async fn from_request(req: Request, state: &S) -> Result { - match Json::::from_request(req, state).await { - Ok(Json(value)) => Ok(AtpJson(value)), - Err(rejection) => { - let message = extract_json_error_message(&rejection); - Err(( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidRequest", - "message": message - })), - )) - } - } - } -} - -fn extract_json_error_message(rejection: &JsonRejection) -> String { - match rejection { - JsonRejection::JsonDataError(e) => { - let inner = e.body_text(); - if inner.contains("missing field") { - let field = inner - .split("missing field `") - .nth(1) - .and_then(|s| s.split('`').next()) - .unwrap_or("unknown"); - format!("Missing required field: {}", field) - } else if inner.contains("invalid type") { - format!("Invalid field type: {}", inner) - } else { - inner - } - } - JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(), - JsonRejection::MissingJsonContentType(_) => { - "Content-Type must be application/json".to_string() - } - JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(), - _ => "Invalid request body".to_string(), - } -} - pub trait DbResultExt { fn log_db_err(self, ctx: &str) -> Result; } diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index 0b54371..ca0e255 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -5,7 +5,7 @@ use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_servi use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::rate_limit::{AccountCreationLimit, RateLimited}; use crate::state::AppState; -use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; +use crate::types::{Did, Handle, PlainPassword}; use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; use axum::{ @@ -34,7 +34,7 @@ pub struct CreateAccountInput { pub did: Option, pub did_type: Option, pub signing_key: Option, - pub verification_channel: Option, + pub verification_channel: Option, pub discord_username: Option, pub telegram_username: Option, pub signal_username: Option, @@ -50,7 +50,7 @@ pub struct CreateAccountOutput { pub access_jwt: String, pub refresh_jwt: String, pub verification_required: bool, - pub verification_channel: String, + pub verification_channel: tranquil_db_traits::CommsChannel, } pub async fn create_account( @@ -73,9 +73,9 @@ pub async fn create_account( info!("create_account called"); } - let migration_auth = if let Some(extracted) = - extract_auth_token_from_header(crate::util::get_header_str(&headers, "Authorization")) - { + let migration_auth = if let Some(extracted) = extract_auth_token_from_header( + crate::util::get_header_str(&headers, http::header::AUTHORIZATION), + ) { let token = extracted.token; if is_service_token(&token) { let verifier = ServiceTokenVerifier::new(); @@ -190,20 +190,18 @@ pub async fn create_account( { return ApiError::InvalidEmail.into_response(); } - let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); - let valid_channels = ["email", "discord", "telegram", "signal"]; - if !valid_channels.contains(&verification_channel) && !is_migration { - return ApiError::InvalidVerificationChannel.into_response(); - } + let verification_channel = input + .verification_channel + .unwrap_or(tranquil_db_traits::CommsChannel::Email); let verification_recipient = if is_migration { None } else { Some(match verification_channel { - "email" => match &input.email { + tranquil_db_traits::CommsChannel::Email => match &input.email { Some(email) if !email.trim().is_empty() => email.trim().to_string(), _ => return ApiError::MissingEmail.into_response(), }, - "discord" => match &input.discord_username { + tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().to_lowercase(); if !crate::api::validation::is_valid_discord_username(&clean) { @@ -215,7 +213,7 @@ pub async fn create_account( } _ => return ApiError::MissingDiscordId.into_response(), }, - "telegram" => match &input.telegram_username { + tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().trim_start_matches('@'); if !crate::api::validation::is_valid_telegram_username(clean) { @@ -227,13 +225,12 @@ pub async fn create_account( } _ => return ApiError::MissingTelegramUsername.into_response(), }, - "signal" => match &input.signal_username { + tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { Some(username) if !username.trim().is_empty() => { username.trim().trim_start_matches('@').to_lowercase() } _ => return ApiError::MissingSignalNumber.into_response(), }, - _ => return ApiError::InvalidVerificationChannel.into_response(), }) }; let hostname = pds_hostname(); @@ -304,7 +301,7 @@ pub async fn create_account( && let Err(e) = verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()).await { - return ApiError::InvalidDid(e).into_response(); + return ApiError::InvalidDid(e.to_string()).into_response(); } info!(did = %d, "Creating external did:web account"); d.clone() @@ -320,7 +317,7 @@ pub async fn create_account( verify_did_web(d, hostname, &input.handle, input.signing_key.as_deref()) .await { - return ApiError::InvalidDid(e).into_response(); + return ApiError::InvalidDid(e.to_string()).into_response(); } d.clone() } else if !d.trim().is_empty() { @@ -397,9 +394,17 @@ pub async fn create_account( } }; if is_migration { + let did_typed: Did = match did.parse() { + Ok(d) => d, + Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), + }; + let handle_typed: Handle = match handle.parse() { + Ok(h) => h, + Err(_) => return ApiError::InvalidHandle(None).into_response(), + }; let reactivate_input = tranquil_db_traits::MigrationReactivationInput { - did: unsafe { Did::new_unchecked(&did) }, - new_handle: unsafe { Handle::new_unchecked(&handle) }, + did: did_typed.clone(), + new_handle: handle_typed.clone(), new_email: email.clone(), }; match state @@ -453,7 +458,7 @@ pub async fn create_account( } }; let session_data = tranquil_db_traits::SessionTokenCreate { - did: unsafe { Did::new_unchecked(&did) }, + did: did_typed.clone(), access_jti: access_meta.jti.clone(), refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, @@ -470,8 +475,9 @@ pub async fn create_account( } let hostname = pds_hostname(); let verification_required = if let Some(ref user_email) = email { - let token = - crate::auth::verification_token::generate_migration_token(&did, user_email); + let token = crate::auth::verification_token::generate_migration_token( + &did_typed, user_email, + ); let formatted_token = crate::auth::verification_token::format_token_for_display(&token); if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification( @@ -494,12 +500,12 @@ pub async fn create_account( axum::http::StatusCode::OK, Json(CreateAccountOutput { handle: handle.clone().into(), - did: unsafe { Did::new_unchecked(&did) }, + did: did_typed.clone(), did_doc: state.did_resolver.resolve_did_document(&did).await, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, verification_required, - verification_channel: "email".to_string(), + verification_channel: tranquil_db_traits::CommsChannel::Email, }), ) .into_response(); @@ -518,7 +524,10 @@ pub async fn create_account( } } - let handle_typed = unsafe { Handle::new_unchecked(&handle) }; + let handle_typed: Handle = match handle.parse() { + Ok(h) => h, + Err(_) => return ApiError::InvalidHandle(None).into_response(), + }; let handle_available = match state .user_repo .check_handle_available_for_new_account(&handle_typed) @@ -534,9 +543,7 @@ pub async fn create_account( return ApiError::HandleTaken.into_response(); } - let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); if invite_code_required && input .invite_code @@ -602,7 +609,10 @@ pub async fn create_account( } }; let rev = Tid::now(LimitedU32::MIN); - let did_for_commit = unsafe { Did::new_unchecked(&did) }; + let did_for_commit: Did = match did.parse() { + Ok(d) => d, + Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), + }; let (commit_bytes, _sig) = match create_signed_commit(&did_for_commit, mst_root, rev.as_ref(), None, &signing_key) { Ok(result) => result, @@ -629,18 +639,12 @@ pub async fn create_account( }) }); - let preferred_comms_channel = match verification_channel { - "email" => tranquil_db_traits::CommsChannel::Email, - "discord" => tranquil_db_traits::CommsChannel::Discord, - "telegram" => tranquil_db_traits::CommsChannel::Telegram, - "signal" => tranquil_db_traits::CommsChannel::Signal, - _ => tranquil_db_traits::CommsChannel::Email, - }; + let preferred_comms_channel = verification_channel; let create_input = tranquil_db_traits::CreatePasswordAccountInput { - handle: unsafe { Handle::new_unchecked(&handle) }, + handle: handle_typed.clone(), email: email.clone(), - did: unsafe { Did::new_unchecked(&did) }, + did: did_for_commit.clone(), password_hash, preferred_comms_channel, discord_username: input @@ -689,11 +693,9 @@ pub async fn create_account( }; let user_id = create_result.user_id; if !is_migration && !is_did_web_byod { - let did_typed = unsafe { Did::new_unchecked(&did) }; - let handle_typed = unsafe { Handle::new_unchecked(&handle) }; if let Err(e) = crate::api::repo::record::sequence_identity_event( &state, - &did_typed, + &did_for_commit, Some(&handle_typed), ) .await @@ -702,7 +704,7 @@ pub async fn create_account( } if let Err(e) = crate::api::repo::record::sequence_account_event( &state, - &did_typed, + &did_for_commit, tranquil_db_traits::AccountStatus::Active, ) .await @@ -711,7 +713,7 @@ pub async fn create_account( } if let Err(e) = crate::api::repo::record::sequence_genesis_commit( &state, - &did_typed, + &did_for_commit, &commit_cid, &mst_root, &rev_str, @@ -722,7 +724,7 @@ pub async fn create_account( } if let Err(e) = crate::api::repo::record::sequence_sync_event( &state, - &did_typed, + &did_for_commit, &commit_cid_str, Some(rev.as_ref()), ) @@ -734,13 +736,11 @@ pub async fn create_account( "$type": "app.bsky.actor.profile", "displayName": input.handle }); - let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; - let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, - &did_typed, - &profile_collection, - &profile_rkey, + &did_for_commit, + &crate::types::PROFILE_COLLECTION, + &crate::types::PROFILE_RKEY, &profile_record, ) .await @@ -752,7 +752,7 @@ pub async fn create_account( if !is_migration { if let Some(ref recipient) = verification_recipient { let verification_token = crate::auth::verification_token::generate_signup_token( - &did, + &did_for_commit, verification_channel, recipient, ); @@ -776,7 +776,8 @@ pub async fn create_account( } } } else if let Some(ref user_email) = email { - let token = crate::auth::verification_token::generate_migration_token(&did, user_email); + let token = + crate::auth::verification_token::generate_migration_token(&did_for_commit, user_email); let formatted_token = crate::auth::verification_token::format_token_for_display(&token); if let Err(e) = crate::comms::comms_repo::enqueue_migration_verification( state.user_repo.as_ref(), @@ -809,7 +810,7 @@ pub async fn create_account( } }; let session_data = tranquil_db_traits::SessionTokenCreate { - did: unsafe { Did::new_unchecked(&did) }, + did: did_for_commit.clone(), access_jti: access_meta.jti.clone(), refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, @@ -838,12 +839,12 @@ pub async fn create_account( StatusCode::OK, Json(CreateAccountOutput { handle: handle.clone().into(), - did: unsafe { Did::new_unchecked(&did) }, + did: did_for_commit, did_doc, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, verification_required: !is_migration, - verification_channel: verification_channel.to_string(), + verification_channel, }), ) .into_response() diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index cc059b6..e4736a9 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -42,7 +42,7 @@ pub async fn resolve_handle( if handle_str.is_empty() { return ApiError::InvalidRequest("handle is required".into()).into_response(); } - let cache_key = format!("handle:{}", handle_str); + let cache_key = crate::cache_keys::handle_key(handle_str); if let Some(did) = state.cache.get(&cache_key).await { return DidResponse::response(did).into_response(); } @@ -78,12 +78,29 @@ pub async fn resolve_handle( } } -pub fn get_jwk(key_bytes: &[u8]) -> Result { - let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?; +#[derive(Debug)] +pub enum KeyError { + InvalidKeyLength, + MissingCoordinate, +} + +impl std::fmt::Display for KeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidKeyLength => write!(f, "invalid key length"), + Self::MissingCoordinate => write!(f, "missing elliptic curve coordinate"), + } + } +} + +impl std::error::Error for KeyError {} + +pub fn get_jwk(key_bytes: &[u8]) -> Result { + let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?; let public_key = secret_key.public_key(); let encoded = public_key.to_encoded_point(false); - let x = encoded.x().ok_or("Missing x coordinate")?; - let y = encoded.y().ok_or("Missing y coordinate")?; + let x = encoded.x().ok_or(KeyError::MissingCoordinate)?; + let y = encoded.y().ok_or(KeyError::MissingCoordinate)?; let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x); let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y); Ok(json!({ @@ -94,8 +111,8 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result { })) } -pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result { - let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?; +pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result { + let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| KeyError::InvalidKeyLength)?; let public_key = secret_key.public_key(); let compressed = public_key.to_encoded_point(true); let compressed_bytes = compressed.as_bytes(); @@ -107,7 +124,7 @@ pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result, headers: HeaderMap) -> Response { let hostname = pds_hostname(); let hostname_without_port = pds_hostname_without_port(); - let host_header = get_header_str(&headers, "host").unwrap_or(hostname); + let host_header = get_header_str(&headers, http::header::HOST).unwrap_or(hostname); let host_without_port = host_header.split(':').next().unwrap_or(host_header); if host_without_port != hostname_without_port && host_without_port.ends_with(&format!(".{}", hostname_without_port)) @@ -127,7 +144,7 @@ pub async fn well_known_did(State(state): State, headers: HeaderMap) - "id": did, "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": format!("https://{}", hostname) }] })) @@ -197,7 +214,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s })).collect::>(), "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] })) @@ -250,7 +267,7 @@ async fn serve_subdomain_did_doc(state: &AppState, subdomain: &str, hostname: &s }], "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] })) @@ -332,7 +349,7 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path>(), "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] })) @@ -385,19 +402,43 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path, -) -> Result<(), String> { +) -> Result<(), DidWebVerifyError> { let hostname_for_handles = hostname.split(':').next().unwrap_or(hostname); let subdomain_host = format!("{}.{}", handle, hostname_for_handles); let encoded_subdomain = subdomain_host.replace(':', "%3A"); @@ -413,21 +454,16 @@ pub async fn verify_did_web( if did.starts_with(&expected_prefix) { let suffix = &did[expected_prefix.len()..]; let expected_suffix = format!(":u:{}", handle); - if suffix == expected_suffix { - return Ok(()); + return if suffix == expected_suffix { + Ok(()) } else { - return Err(format!( - "Invalid DID path for this PDS. Expected {}", - expected_suffix - )); - } + Err(DidWebVerifyError::InvalidPath(expected_suffix)) + }; } - let expected_signing_key = expected_signing_key.ok_or_else(|| { - "External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string() - })?; + let expected_signing_key = expected_signing_key.ok_or(DidWebVerifyError::MissingSigningKey)?; let parts: Vec<&str> = did.split(':').collect(); if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { - return Err("Invalid did:web format".into()); + return Err(DidWebVerifyError::InvalidFormat); } let domain_segment = parts[2]; let domain = domain_segment.replace("%3A", ":"); @@ -447,43 +483,46 @@ pub async fn verify_did_web( .get(&url) .send() .await - .map_err(|e| format!("Failed to fetch DID doc: {}", e))?; + .map_err(|e| DidWebVerifyError::FetchFailed(e.to_string()))?; if !resp.status().is_success() { - return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status())); + return Err(DidWebVerifyError::FetchFailed(format!( + "HTTP {}", + resp.status() + ))); } let doc: serde_json::Value = resp .json() .await - .map_err(|e| format!("Failed to parse DID doc: {}", e))?; + .map_err(|e| DidWebVerifyError::InvalidDocument(e.to_string()))?; let services = doc["service"] .as_array() - .ok_or("No services found in DID doc")?; + .ok_or(DidWebVerifyError::InvalidDocument( + "No services found".to_string(), + ))?; let pds_endpoint = format!("https://{}", hostname); - let has_valid_service = services - .iter() - .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); + let has_valid_service = services.iter().any(|s| { + s["type"] == crate::plc::ServiceType::Pds.as_str() && s["serviceEndpoint"] == pds_endpoint + }); if !has_valid_service { - return Err(format!( - "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", - pds_endpoint - )); + return Err(DidWebVerifyError::PdsNotListed(pds_endpoint)); } - let verification_methods = doc["verificationMethod"] - .as_array() - .ok_or("No verificationMethod found in DID doc")?; + let verification_methods = + doc["verificationMethod"] + .as_array() + .ok_or(DidWebVerifyError::InvalidDocument( + "No verificationMethod found".to_string(), + ))?; let expected_multibase = expected_signing_key .strip_prefix("did:key:") - .ok_or("Invalid signing key format")?; + .ok_or(DidWebVerifyError::InvalidSigningKey)?; let has_matching_key = verification_methods.iter().any(|vm| { vm["publicKeyMultibase"] .as_str() - .map(|pk| pk == expected_multibase) - .unwrap_or(false) + .is_some_and(|pk| pk == expected_multibase) }); if !has_matching_key { - return Err(format!( - "DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}", - expected_multibase + return Err(DidWebVerifyError::KeyMismatch( + expected_multibase.to_string(), )); } Ok(()) @@ -559,7 +598,7 @@ pub async fn get_recommended_did_credentials( verification_methods: VerificationMethods { atproto: did_key }, services: Services { atproto_pds: AtprotoPds { - service_type: "AtprotoPersonalDataServer".to_string(), + service_type: crate::plc::ServiceType::Pds.as_str().to_string(), endpoint: pds_endpoint, }, }, @@ -579,7 +618,7 @@ pub async fn update_handle( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_identity_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Handle, ) { @@ -652,7 +691,10 @@ pub async fn update_handle( format!("{}.{}", new_handle, hostname_for_handles) }; if full_handle == current_handle { - let handle_typed = unsafe { Handle::new_unchecked(&full_handle) }; + let handle_typed: Handle = match full_handle.parse() { + Ok(h) => h, + Err(_) => return Err(ApiError::InvalidHandle(None)), + }; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)) .await @@ -675,7 +717,10 @@ pub async fn update_handle( full_handle } else { if new_handle == current_handle { - let handle_typed = unsafe { Handle::new_unchecked(&new_handle) }; + let handle_typed: Handle = match new_handle.parse() { + Ok(h) => h, + Err(_) => return Err(ApiError::InvalidHandle(None)), + }; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)) .await @@ -728,10 +773,13 @@ pub async fn update_handle( if !current_handle.is_empty() { let _ = state .cache - .delete(&format!("handle:{}", current_handle)) + .delete(&crate::cache_keys::handle_key(¤t_handle)) .await; } - let _ = state.cache.delete(&format!("handle:{}", handle)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&handle)) + .await; if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle_typed)).await { @@ -768,7 +816,7 @@ pub async fn update_plc_handle( } pub async fn well_known_atproto_did(State(state): State, headers: HeaderMap) -> Response { - let host = match crate::util::get_header_str(&headers, "host") { + let host = match crate::util::get_header_str(&headers, http::header::HOST) { Some(h) => h, None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), }; diff --git a/crates/tranquil-pds/src/api/identity/handle.rs b/crates/tranquil-pds/src/api/identity/handle.rs index 5a86684..28f6be7 100644 --- a/crates/tranquil-pds/src/api/identity/handle.rs +++ b/crates/tranquil-pds/src/api/identity/handle.rs @@ -1,4 +1,3 @@ -use crate::api::error::ApiError; use crate::rate_limit::{HandleVerificationLimit, RateLimited}; use crate::types::{Did, Handle}; use axum::{ @@ -9,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct VerifyHandleOwnershipInput { - pub handle: String, + pub handle: Handle, pub did: Did, } @@ -27,14 +26,7 @@ pub async fn verify_handle_ownership( _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let handle: Handle = match input.handle.parse() { - Ok(h) => h, - Err(_) => { - return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response(); - } - }; - - let handle_str = handle.as_str(); + let handle_str = input.handle.as_str(); let did_str = input.did.as_str(); let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await { diff --git a/crates/tranquil-pds/src/api/identity/plc/request.rs b/crates/tranquil-pds/src/api/identity/plc/request.rs index 45a1ef7..570859f 100644 --- a/crates/tranquil-pds/src/api/identity/plc/request.rs +++ b/crates/tranquil-pds/src/api/identity/plc/request.rs @@ -19,7 +19,7 @@ pub async fn request_plc_operation_signature( auth: Auth, ) -> Result { if let Err(e) = crate::auth::scope_check::check_identity_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Wildcard, ) { diff --git a/crates/tranquil-pds/src/api/identity/plc/sign.rs b/crates/tranquil-pds/src/api/identity/plc/sign.rs index 040dbc1..ceaa82d 100644 --- a/crates/tranquil-pds/src/api/identity/plc/sign.rs +++ b/crates/tranquil-pds/src/api/identity/plc/sign.rs @@ -2,7 +2,7 @@ use crate::api::ApiError; use crate::api::error::DbResultExt; use crate::auth::{Auth, Permissive}; use crate::circuit_breaker::with_circuit_breaker; -use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation}; +use crate::plc::{PlcClient, PlcError, PlcService, ServiceType, create_update_op, sign_operation}; use crate::state::AppState; use axum::{ Json, @@ -30,7 +30,7 @@ pub struct SignPlcOperationInput { #[derive(Debug, Deserialize, Clone)] pub struct ServiceInput { #[serde(rename = "type")] - pub service_type: String, + pub service_type: ServiceType, pub endpoint: String, } @@ -45,7 +45,7 @@ pub async fn sign_plc_operation( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_identity_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Wildcard, ) { diff --git a/crates/tranquil-pds/src/api/identity/plc/submit.rs b/crates/tranquil-pds/src/api/identity/plc/submit.rs index 5c11db1..6b4c505 100644 --- a/crates/tranquil-pds/src/api/identity/plc/submit.rs +++ b/crates/tranquil-pds/src/api/identity/plc/submit.rs @@ -26,7 +26,7 @@ pub async fn submit_plc_operation( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_identity_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::IdentityAttr::Wildcard, ) { @@ -87,7 +87,7 @@ pub async fn submit_plc_operation( { let service_type = pds.get("type").and_then(|v| v.as_str()); let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); - if service_type != Some("AtprotoPersonalDataServer") { + if service_type != Some(crate::plc::ServiceType::Pds.as_str()) { return Err(ApiError::InvalidRequest( "Incorrect type on atproto_pds service".into(), )); @@ -143,9 +143,18 @@ pub async fn submit_plc_operation( warn!("Failed to sequence identity event: {:?}", e); } } - let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; - let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; - let _ = state.cache.delete(&format!("plc:data:{}", did)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&user.handle)) + .await; + let _ = state + .cache + .delete(&crate::cache_keys::plc_doc_key(did)) + .await; + let _ = state + .cache + .delete(&crate::cache_keys::plc_data_key(did)) + .await; if state.did_resolver.refresh_did(did).await.is_none() { warn!(did = %did, "Failed to refresh DID cache after PLC update"); } diff --git a/crates/tranquil-pds/src/api/mod.rs b/crates/tranquil-pds/src/api/mod.rs index 3b8c8b3..4d33840 100644 --- a/crates/tranquil-pds/src/api/mod.rs +++ b/crates/tranquil-pds/src/api/mod.rs @@ -19,7 +19,7 @@ pub mod validation; pub mod verification; pub use error::ApiError; -pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit}; +pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit}; pub use responses::{ DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, diff --git a/crates/tranquil-pds/src/api/moderation/mod.rs b/crates/tranquil-pds/src/api/moderation/mod.rs index 2cbe719..2a26ff1 100644 --- a/crates/tranquil-pds/src/api/moderation/mod.rs +++ b/crates/tranquil-pds/src/api/moderation/mod.rs @@ -12,10 +12,42 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tracing::{error, info}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReportReasonType { + #[serde(rename = "com.atproto.moderation.defs#reasonSpam")] + Spam, + #[serde(rename = "com.atproto.moderation.defs#reasonViolation")] + Violation, + #[serde(rename = "com.atproto.moderation.defs#reasonMisleading")] + Misleading, + #[serde(rename = "com.atproto.moderation.defs#reasonSexual")] + Sexual, + #[serde(rename = "com.atproto.moderation.defs#reasonRude")] + Rude, + #[serde(rename = "com.atproto.moderation.defs#reasonOther")] + Other, + #[serde(rename = "com.atproto.moderation.defs#reasonAppeal")] + Appeal, +} + +impl ReportReasonType { + pub fn as_str(self) -> &'static str { + match self { + Self::Spam => "com.atproto.moderation.defs#reasonSpam", + Self::Violation => "com.atproto.moderation.defs#reasonViolation", + Self::Misleading => "com.atproto.moderation.defs#reasonMisleading", + Self::Sexual => "com.atproto.moderation.defs#reasonSexual", + Self::Rude => "com.atproto.moderation.defs#reasonRude", + Self::Other => "com.atproto.moderation.defs#reasonOther", + Self::Appeal => "com.atproto.moderation.defs#reasonAppeal", + } + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateReportInput { - pub reason_type: String, + pub reason_type: ReportReasonType, pub reason: Option, pub subject: Value, } @@ -24,20 +56,25 @@ pub struct CreateReportInput { #[serde(rename_all = "camelCase")] pub struct CreateReportOutput { pub id: i64, - pub reason_type: String, + pub reason_type: ReportReasonType, pub reason: Option, pub subject: Value, pub reported_by: String, pub created_at: String, } -fn get_report_service_config() -> Option<(String, String)> { +struct ReportServiceConfig { + url: String, + did: String, +} + +fn get_report_service_config() -> Option { let url = std::env::var("REPORT_SERVICE_URL").ok()?; let did = std::env::var("REPORT_SERVICE_DID").ok()?; if url.is_empty() || did.is_empty() { return None; } - Some((url, did)) + Some(ReportServiceConfig { url, did }) } pub async fn create_report( @@ -47,8 +84,8 @@ pub async fn create_report( ) -> Response { let did = &auth.did; - if let Some((service_url, service_did)) = get_report_service_config() { - return proxy_to_report_service(&state, &auth, &service_url, &service_did, &input).await; + if let Some(config) = get_report_service_config() { + return proxy_to_report_service(&state, &auth, &config.url, &config.did, &input).await; } create_report_locally(&state, did, auth.status.is_takendown(), input).await @@ -177,36 +214,21 @@ async fn create_report_locally( is_takendown: bool, input: CreateReportInput, ) -> Response { - const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal"; - - if is_takendown && input.reason_type != REASON_APPEAL { + if is_takendown && input.reason_type != ReportReasonType::Appeal { return ApiError::InvalidRequest("Report not accepted from takendown account".into()) .into_response(); } - let valid_reason_types = [ - "com.atproto.moderation.defs#reasonSpam", - "com.atproto.moderation.defs#reasonViolation", - "com.atproto.moderation.defs#reasonMisleading", - "com.atproto.moderation.defs#reasonSexual", - "com.atproto.moderation.defs#reasonRude", - "com.atproto.moderation.defs#reasonOther", - REASON_APPEAL, - ]; - - if !valid_reason_types.contains(&input.reason_type.as_str()) { - return ApiError::InvalidRequest("Invalid reasonType".into()).into_response(); - } - let created_at = chrono::Utc::now(); - let report_id = (uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) as i64; + let report_id = i64::try_from(uuid::Uuid::now_v7().as_u128() & 0x7FFF_FFFF_FFFF_FFFF) + .expect("masked to 63 bits, always fits i64"); let subject_json = json!(input.subject); if let Err(e) = state .infra_repo .insert_report( report_id, - &input.reason_type, + input.reason_type.as_str(), input.reason.as_deref(), subject_json, did, @@ -221,7 +243,7 @@ async fn create_report_locally( info!( report_id = %report_id, reported_by = %did, - reason_type = %input.reason_type, + reason_type = input.reason_type.as_str(), "Report created locally (no report service configured)" ); diff --git a/crates/tranquil-pds/src/api/notification_prefs.rs b/crates/tranquil-pds/src/api/notification_prefs.rs index b45d07d..d9ef9c3 100644 --- a/crates/tranquil-pds/src/api/notification_prefs.rs +++ b/crates/tranquil-pds/src/api/notification_prefs.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::info; use tranquil_db_traits::{CommsChannel, CommsStatus, CommsType}; +use tranquil_types::Did; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -130,91 +131,95 @@ pub struct UpdateNotificationPrefsInput { pub struct UpdateNotificationPrefsResponse { pub success: bool, #[serde(skip_serializing_if = "Vec::is_empty")] - pub verification_required: Vec, + pub verification_required: Vec, } pub async fn request_channel_verification( state: &AppState, user_id: uuid::Uuid, - did: &str, - channel: &str, + did: &Did, + channel: CommsChannel, identifier: &str, handle: Option<&str>, -) -> Result { +) -> Result { let token = crate::auth::verification_token::generate_channel_update_token(did, channel, identifier); let formatted_token = crate::auth::verification_token::format_token_for_display(&token); - if channel == "email" { - let hostname = pds_hostname(); - let handle_str = handle.unwrap_or("user"); - crate::comms::comms_repo::enqueue_email_update( - state.infra_repo.as_ref(), - user_id, - identifier, - handle_str, - &formatted_token, - hostname, - ) - .await - .map_err(|e| format!("Failed to enqueue email notification: {}", e))?; - } else { - let comms_channel = match channel { - "discord" => tranquil_db_traits::CommsChannel::Discord, - "telegram" => tranquil_db_traits::CommsChannel::Telegram, - "signal" => tranquil_db_traits::CommsChannel::Signal, - _ => return Err("Invalid channel".to_string()), - }; - let hostname = pds_hostname(); - let encoded_token = urlencoding::encode(&formatted_token); - let encoded_identifier = urlencoding::encode(identifier); - let verify_link = format!( - "https://{}/app/verify?token={}&identifier={}", - hostname, encoded_token, encoded_identifier - ); - let prefs = state - .user_repo - .get_comms_prefs(user_id) - .await - .ok() - .flatten(); - let locale = prefs - .as_ref() - .and_then(|p| p.preferred_locale.as_deref()) - .unwrap_or("en"); - let strings = crate::comms::get_strings(locale); - let body = crate::comms::format_message( - strings.channel_verification_body, - &[("code", &formatted_token), ("verify_link", &verify_link)], - ); - let subject = crate::comms::format_message( - strings.channel_verification_subject, - &[("hostname", hostname)], - ); - let recipient = match comms_channel { - tranquil_db_traits::CommsChannel::Telegram => state - .user_repo - .get_telegram_chat_id(user_id) - .await - .ok() - .flatten() - .map(|id| id.to_string()) - .unwrap_or_else(|| identifier.to_string()), - _ => identifier.to_string(), - }; - state - .infra_repo - .enqueue_comms( - Some(user_id), - comms_channel, - tranquil_db_traits::CommsType::ChannelVerification, - &recipient, - Some(&subject), - &body, - Some(json!({"code": formatted_token})), + match channel { + CommsChannel::Email => { + let hostname = pds_hostname(); + let handle_str = handle.unwrap_or("user"); + crate::comms::comms_repo::enqueue_email_update( + state.infra_repo.as_ref(), + user_id, + identifier, + handle_str, + &formatted_token, + hostname, ) .await - .map_err(|e| format!("Failed to enqueue notification: {}", e))?; + .map_err(|e| { + ApiError::InternalError(Some(format!( + "Failed to enqueue email notification: {}", + e + ))) + })?; + } + _ => { + let hostname = pds_hostname(); + let encoded_token = urlencoding::encode(&formatted_token); + let encoded_identifier = urlencoding::encode(identifier); + let verify_link = format!( + "https://{}/app/verify?token={}&identifier={}", + hostname, encoded_token, encoded_identifier + ); + let prefs = state + .user_repo + .get_comms_prefs(user_id) + .await + .ok() + .flatten(); + let locale = prefs + .as_ref() + .and_then(|p| p.preferred_locale.as_deref()) + .unwrap_or("en"); + let strings = crate::comms::get_strings(locale); + let body = crate::comms::format_message( + strings.channel_verification_body, + &[("code", &formatted_token), ("verify_link", &verify_link)], + ); + let subject = crate::comms::format_message( + strings.channel_verification_subject, + &[("hostname", hostname)], + ); + let recipient = match channel { + CommsChannel::Telegram => state + .user_repo + .get_telegram_chat_id(user_id) + .await + .ok() + .flatten() + .map(|id| id.to_string()) + .unwrap_or_else(|| identifier.to_string()), + _ => identifier.to_string(), + }; + state + .infra_repo + .enqueue_comms( + Some(user_id), + channel, + tranquil_db_traits::CommsType::ChannelVerification, + &recipient, + Some(&subject), + &body, + Some(json!({"code": formatted_token})), + ) + .await + .map_err(|e| { + ApiError::InternalError(Some(format!("Failed to enqueue notification: {}", e))) + })?; + } } Ok(token) @@ -246,38 +251,25 @@ pub async fn update_notification_prefs( let effective_channel = input .preferred_channel .as_deref() - .map(|ch| match ch { - "email" => Ok(CommsChannel::Email), - "discord" => Ok(CommsChannel::Discord), - "telegram" => Ok(CommsChannel::Telegram), - "signal" => Ok(CommsChannel::Signal), - _ => Err(ApiError::InvalidRequest( - "Invalid channel. Must be one of: email, discord, telegram, signal".into(), - )), + .map(|ch| { + ch.parse::().map_err(|_| { + ApiError::InvalidRequest( + "Invalid channel. Must be one of: email, discord, telegram, signal".into(), + ) + }) }) .transpose()? .unwrap_or(current_prefs.preferred_channel); - let mut verification_required: Vec = Vec::new(); + let mut verification_required: Vec = Vec::new(); - if let Some(ref channel_str) = input.preferred_channel { - let channel = match channel_str.as_str() { - "email" => CommsChannel::Email, - "discord" => CommsChannel::Discord, - "telegram" => CommsChannel::Telegram, - "signal" => CommsChannel::Signal, - _ => { - return Err(ApiError::InvalidRequest( - "Invalid channel. Must be one of: email, discord, telegram, signal".into(), - )); - } - }; + if input.preferred_channel.is_some() { state .user_repo - .update_preferred_comms_channel(&auth.did, channel) + .update_preferred_comms_channel(&auth.did, effective_channel) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - info!(did = %auth.did, channel = ?channel, "Updated preferred notification channel"); + info!(did = %auth.did, channel = ?effective_channel, "Updated preferred notification channel"); } if let Some(ref new_email) = input.email { @@ -295,13 +287,12 @@ pub async fn update_notification_prefs( &state, user_id, &auth.did, - "email", + CommsChannel::Email, &email_clean, Some(&handle), ) - .await - .map_err(|e| ApiError::InternalError(Some(e)))?; - verification_required.push("email".to_string()); + .await?; + verification_required.push(CommsChannel::Email); info!(did = %auth.did, "Requested email verification"); } } @@ -331,7 +322,7 @@ pub async fn update_notification_prefs( .set_unverified_discord(user_id, &discord_clean) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - verification_required.push("discord".to_string()); + verification_required.push(CommsChannel::Discord); info!(did = %auth.did, discord_username = %discord_clean, "Stored unverified Discord username"); } } @@ -361,7 +352,7 @@ pub async fn update_notification_prefs( .set_unverified_telegram(user_id, telegram_clean) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - verification_required.push("telegram".to_string()); + verification_required.push(CommsChannel::Telegram); info!(did = %auth.did, telegram_username = %telegram_clean, "Stored unverified Telegram username"); } } @@ -391,10 +382,16 @@ pub async fn update_notification_prefs( .set_unverified_signal(user_id, &signal_clean) .await .map_err(|e| ApiError::InternalError(Some(format!("Database error: {}", e))))?; - request_channel_verification(&state, user_id, &auth.did, "signal", &signal_clean, None) - .await - .map_err(|e| ApiError::InternalError(Some(e)))?; - verification_required.push("signal".to_string()); + request_channel_verification( + &state, + user_id, + &auth.did, + CommsChannel::Signal, + &signal_clean, + None, + ) + .await?; + verification_required.push(CommsChannel::Signal); info!(did = %auth.did, signal_username = %signal_clean, "Stored unverified Signal username"); } } diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index a5021f9..1f46283 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -1,4 +1,6 @@ +use std::collections::HashSet; use std::convert::Infallible; +use std::sync::LazyLock; use crate::api::error::ApiError; use crate::api::proxy_client::proxy_client; @@ -15,92 +17,96 @@ use futures_util::future::Either; use tower::{Service, util::BoxCloneSyncService}; use tracing::{error, info, warn}; -const PROTECTED_METHODS: &[&str] = &[ - "app.bsky.actor.getPreferences", - "app.bsky.actor.putPreferences", - "com.atproto.admin.deleteAccount", - "com.atproto.admin.disableAccountInvites", - "com.atproto.admin.disableInviteCodes", - "com.atproto.admin.enableAccountInvites", - "com.atproto.admin.getAccountInfo", - "com.atproto.admin.getAccountInfos", - "com.atproto.admin.getInviteCodes", - "com.atproto.admin.getSubjectStatus", - "com.atproto.admin.searchAccounts", - "com.atproto.admin.sendEmail", - "com.atproto.admin.updateAccountEmail", - "com.atproto.admin.updateAccountHandle", - "com.atproto.admin.updateAccountPassword", - "com.atproto.admin.updateSubjectStatus", - "com.atproto.identity.getRecommendedDidCredentials", - "com.atproto.identity.requestPlcOperationSignature", - "com.atproto.identity.signPlcOperation", - "com.atproto.identity.submitPlcOperation", - "com.atproto.identity.updateHandle", - "com.atproto.repo.applyWrites", - "com.atproto.repo.createRecord", - "com.atproto.repo.deleteRecord", - "com.atproto.repo.importRepo", - "com.atproto.repo.putRecord", - "com.atproto.repo.uploadBlob", - "com.atproto.server.activateAccount", - "com.atproto.server.checkAccountStatus", - "com.atproto.server.confirmEmail", - "com.atproto.server.confirmSignup", - "com.atproto.server.createAccount", - "com.atproto.server.createAppPassword", - "com.atproto.server.createInviteCode", - "com.atproto.server.createInviteCodes", - "com.atproto.server.createSession", - "com.atproto.server.createTotpSecret", - "com.atproto.server.deactivateAccount", - "com.atproto.server.deleteAccount", - "com.atproto.server.deletePasskey", - "com.atproto.server.deleteSession", - "com.atproto.server.describeServer", - "com.atproto.server.disableTotp", - "com.atproto.server.enableTotp", - "com.atproto.server.finishPasskeyRegistration", - "com.atproto.server.getAccountInviteCodes", - "com.atproto.server.getServiceAuth", - "com.atproto.server.getSession", - "com.atproto.server.getTotpStatus", - "com.atproto.server.listAppPasswords", - "com.atproto.server.listPasskeys", - "com.atproto.server.refreshSession", - "com.atproto.server.regenerateBackupCodes", - "com.atproto.server.requestAccountDelete", - "com.atproto.server.requestEmailConfirmation", - "com.atproto.server.requestEmailUpdate", - "com.atproto.server.requestPasswordReset", - "com.atproto.server.resendMigrationVerification", - "com.atproto.server.resendVerification", - "com.atproto.server.reserveSigningKey", - "com.atproto.server.resetPassword", - "com.atproto.server.revokeAppPassword", - "com.atproto.server.startPasskeyRegistration", - "com.atproto.server.updateEmail", - "com.atproto.server.updatePasskey", - "com.atproto.server.verifyMigrationEmail", - "com.atproto.sync.getBlob", - "com.atproto.sync.getBlocks", - "com.atproto.sync.getCheckout", - "com.atproto.sync.getHead", - "com.atproto.sync.getLatestCommit", - "com.atproto.sync.getRecord", - "com.atproto.sync.getRepo", - "com.atproto.sync.getRepoStatus", - "com.atproto.sync.listBlobs", - "com.atproto.sync.listRepos", - "com.atproto.sync.notifyOfUpdate", - "com.atproto.sync.requestCrawl", - "com.atproto.sync.subscribeRepos", - "com.atproto.temp.checkSignupQueue", - "com.atproto.temp.dereferenceScope", -]; +static PROTECTED_METHODS: LazyLock> = LazyLock::new(|| { + [ + "app.bsky.actor.getPreferences", + "app.bsky.actor.putPreferences", + "com.atproto.admin.deleteAccount", + "com.atproto.admin.disableAccountInvites", + "com.atproto.admin.disableInviteCodes", + "com.atproto.admin.enableAccountInvites", + "com.atproto.admin.getAccountInfo", + "com.atproto.admin.getAccountInfos", + "com.atproto.admin.getInviteCodes", + "com.atproto.admin.getSubjectStatus", + "com.atproto.admin.searchAccounts", + "com.atproto.admin.sendEmail", + "com.atproto.admin.updateAccountEmail", + "com.atproto.admin.updateAccountHandle", + "com.atproto.admin.updateAccountPassword", + "com.atproto.admin.updateSubjectStatus", + "com.atproto.identity.getRecommendedDidCredentials", + "com.atproto.identity.requestPlcOperationSignature", + "com.atproto.identity.signPlcOperation", + "com.atproto.identity.submitPlcOperation", + "com.atproto.identity.updateHandle", + "com.atproto.repo.applyWrites", + "com.atproto.repo.createRecord", + "com.atproto.repo.deleteRecord", + "com.atproto.repo.importRepo", + "com.atproto.repo.putRecord", + "com.atproto.repo.uploadBlob", + "com.atproto.server.activateAccount", + "com.atproto.server.checkAccountStatus", + "com.atproto.server.confirmEmail", + "com.atproto.server.confirmSignup", + "com.atproto.server.createAccount", + "com.atproto.server.createAppPassword", + "com.atproto.server.createInviteCode", + "com.atproto.server.createInviteCodes", + "com.atproto.server.createSession", + "com.atproto.server.createTotpSecret", + "com.atproto.server.deactivateAccount", + "com.atproto.server.deleteAccount", + "com.atproto.server.deletePasskey", + "com.atproto.server.deleteSession", + "com.atproto.server.describeServer", + "com.atproto.server.disableTotp", + "com.atproto.server.enableTotp", + "com.atproto.server.finishPasskeyRegistration", + "com.atproto.server.getAccountInviteCodes", + "com.atproto.server.getServiceAuth", + "com.atproto.server.getSession", + "com.atproto.server.getTotpStatus", + "com.atproto.server.listAppPasswords", + "com.atproto.server.listPasskeys", + "com.atproto.server.refreshSession", + "com.atproto.server.regenerateBackupCodes", + "com.atproto.server.requestAccountDelete", + "com.atproto.server.requestEmailConfirmation", + "com.atproto.server.requestEmailUpdate", + "com.atproto.server.requestPasswordReset", + "com.atproto.server.resendMigrationVerification", + "com.atproto.server.resendVerification", + "com.atproto.server.reserveSigningKey", + "com.atproto.server.resetPassword", + "com.atproto.server.revokeAppPassword", + "com.atproto.server.startPasskeyRegistration", + "com.atproto.server.updateEmail", + "com.atproto.server.updatePasskey", + "com.atproto.server.verifyMigrationEmail", + "com.atproto.sync.getBlob", + "com.atproto.sync.getBlocks", + "com.atproto.sync.getCheckout", + "com.atproto.sync.getHead", + "com.atproto.sync.getLatestCommit", + "com.atproto.sync.getRecord", + "com.atproto.sync.getRepo", + "com.atproto.sync.getRepoStatus", + "com.atproto.sync.listBlobs", + "com.atproto.sync.listRepos", + "com.atproto.sync.notifyOfUpdate", + "com.atproto.sync.requestCrawl", + "com.atproto.sync.subscribeRepos", + "com.atproto.temp.checkSignupQueue", + "com.atproto.temp.dereferenceScope", + ] + .into_iter() + .collect() +}); fn is_protected_method(method: &str) -> bool { - PROTECTED_METHODS.contains(&method) + PROTECTED_METHODS.contains(method) } pub struct XrpcProxyLayer { @@ -192,7 +198,9 @@ async fn proxy_handler( .into_response(); } - let Some(proxy_header) = get_header_str(&headers, "atproto-proxy").map(String::from) else { + let Some(proxy_header) = + get_header_str(&headers, crate::util::HEADER_ATPROTO_PROXY).map(String::from) + else { return ApiError::InvalidRequest("Missing required atproto-proxy header".into()) .into_response(); }; @@ -212,30 +220,29 @@ async fn proxy_handler( let client = proxy_client(); let mut request_builder = client.request(method_verb.clone(), &target_url); - let mut auth_header_val = headers.get("Authorization").cloned(); + let mut auth_header_val = headers.get(http::header::AUTHORIZATION).cloned(); if let Some(extracted) = crate::auth::extract_auth_token_from_header( - crate::util::get_header_str(&headers, "Authorization"), + crate::util::get_header_str(&headers, http::header::AUTHORIZATION), ) { let token = extracted.token; - let dpop_proof = crate::util::get_header_str(&headers, "DPoP"); + let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP); let http_uri = crate::util::build_full_url(&format!("/xrpc{}", uri)); match crate::auth::validate_token_with_dpop( state.user_repo.as_ref(), state.oauth_repo.as_ref(), &token, - extracted.is_dpop, + extracted.scheme, dpop_proof, method_verb.as_str(), &http_uri, - false, - false, + crate::auth::AccountRequirement::Active, ) .await { Ok(auth_user) => { if let Err(e) = crate::auth::scope_check::check_rpc_scope( - auth_user.is_oauth(), + &auth_user.auth_source, auth_user.scope.as_deref(), &resolved.did, method, @@ -298,7 +305,9 @@ async fn proxy_handler( info!(error = ?e, "Proxy token validation failed, returning error to client"); let mut response = ApiError::from(e).into_response(); if let Ok(nonce_val) = crate::oauth::verify::generate_dpop_nonce().parse() { - response.headers_mut().insert("DPoP-Nonce", nonce_val); + response + .headers_mut() + .insert(crate::util::HEADER_DPOP_NONCE, nonce_val); } return response; } @@ -306,13 +315,13 @@ async fn proxy_handler( } if let Some(val) = auth_header_val { - request_builder = request_builder.header("Authorization", val); + request_builder = request_builder.header(http::header::AUTHORIZATION, val); } request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD .iter() - .filter_map(|name| headers.get(*name).map(|val| (*name, val))) + .filter_map(|name| headers.get(name).map(|val| (name, val))) .fold(request_builder, |builder, (name, val)| { - builder.header(name, val) + builder.header(name.as_str(), val) }); if !body.is_empty() { request_builder = request_builder.body(body); @@ -333,7 +342,7 @@ async fn proxy_handler( let mut response_builder = Response::builder().status(status); response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD .iter() - .filter_map(|name| headers.get(*name).map(|val| (*name, val))) + .filter_map(|name| headers.get(name).map(|val| (name, val))) .fold(response_builder, |builder, (name, val)| { builder.header(name, val) }); diff --git a/crates/tranquil-pds/src/api/proxy_client.rs b/crates/tranquil-pds/src/api/proxy_client.rs index fa8d7b0..949d2ad 100644 --- a/crates/tranquil-pds/src/api/proxy_client.rs +++ b/crates/tranquil-pds/src/api/proxy_client.rs @@ -1,8 +1,10 @@ +use axum::http::HeaderName; use reqwest::{Client, ClientBuilder, Url}; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock}; use std::time::Duration; use tracing::warn; +use tranquil_types::{Did, Nsid, Rkey}; pub const DEFAULT_HEADERS_TIMEOUT: Duration = Duration::from_secs(10); pub const DEFAULT_BODY_TIMEOUT: Duration = Duration::from_secs(30); @@ -146,20 +148,24 @@ impl std::fmt::Display for SsrfError { impl std::error::Error for SsrfError {} -pub const HEADERS_TO_FORWARD: &[&str] = &[ - "accept-language", - "atproto-accept-labelers", - "x-bsky-topics", - "content-type", -]; -pub const RESPONSE_HEADERS_TO_FORWARD: &[&str] = &[ - "atproto-repo-rev", - "atproto-content-labelers", - "retry-after", - "content-type", - "cache-control", - "etag", -]; +pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| { + [ + HeaderName::from_static("accept-language"), + crate::util::HEADER_ATPROTO_ACCEPT_LABELERS, + crate::util::HEADER_X_BSKY_TOPICS, + http::header::CONTENT_TYPE, + ] +}); +pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| { + [ + crate::util::HEADER_ATPROTO_REPO_REV, + crate::util::HEADER_ATPROTO_CONTENT_LABELERS, + HeaderName::from_static("retry-after"), + http::header::CONTENT_TYPE, + http::header::CACHE_CONTROL, + http::header::ETAG, + ] +}); pub fn validate_at_uri(uri: &str) -> Result { if !uri.starts_with("at://") { @@ -170,28 +176,29 @@ pub fn validate_at_uri(uri: &str) -> Result { if parts.is_empty() { return Err("URI missing DID"); } - let did = parts[0]; - if !did.starts_with("did:") { - return Err("Invalid DID in URI"); - } - if parts.len() > 1 { - let collection = parts[1]; - if collection.is_empty() || !collection.contains('.') { - return Err("Invalid collection NSID"); - } - } + let did: Did = parts[0].parse().map_err(|_| "Invalid DID in URI")?; + let collection = parts + .get(1) + .map(|s| s.parse::()) + .transpose() + .map_err(|_| "Invalid collection NSID")?; + let rkey = parts + .get(2) + .map(|s| s.parse::()) + .transpose() + .map_err(|_| "Invalid rkey")?; Ok(AtUriParts { - did: did.to_string(), - collection: parts.get(1).map(|s| s.to_string()), - rkey: parts.get(2).map(|s| s.to_string()), + did, + collection, + rkey, }) } #[derive(Debug, Clone)] pub struct AtUriParts { - pub did: String, - pub collection: Option, - pub rkey: Option, + pub did: Did, + pub collection: Option, + pub rkey: Option, } pub fn validate_limit(limit: Option, default: u32, max: u32) -> u32 { @@ -203,21 +210,6 @@ pub fn validate_limit(limit: Option, default: u32, max: u32) -> u32 { } } -pub fn validate_did(did: &str) -> Result<(), &'static str> { - if !did.starts_with("did:") { - return Err("Invalid DID format"); - } - let parts: Vec<&str> = did.split(':').collect(); - if parts.len() < 3 { - return Err("DID must have at least method and identifier"); - } - let method = parts[1]; - if method != "plc" && method != "web" { - return Err("Unsupported DID method"); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -243,9 +235,12 @@ mod tests { let result = validate_at_uri("at://did:plc:test/app.bsky.feed.post/abc123"); assert!(result.is_ok()); let parts = result.unwrap(); - assert_eq!(parts.did, "did:plc:test"); - assert_eq!(parts.collection, Some("app.bsky.feed.post".to_string())); - assert_eq!(parts.rkey, Some("abc123".to_string())); + assert_eq!(parts.did, "did:plc:test".parse::().unwrap()); + assert_eq!( + parts.collection, + Some("app.bsky.feed.post".parse::().unwrap()) + ); + assert_eq!(parts.rkey, Some("abc123".parse::().unwrap())); } #[test] fn test_validate_at_uri_invalid() { @@ -259,11 +254,4 @@ mod tests { assert_eq!(validate_limit(Some(200), 50, 100), 100); assert_eq!(validate_limit(Some(75), 50, 100), 75); } - #[test] - fn test_validate_did() { - assert!(validate_did("did:plc:abc123").is_ok()); - assert!(validate_did("did:web:example.com").is_ok()); - assert!(validate_did("notadid").is_err()); - assert!(validate_did("did:unknown:test").is_err()); - } } diff --git a/crates/tranquil-pds/src/api/repo/blob.rs b/crates/tranquil-pds/src/api/repo/blob.rs index 81eac65..52dc31a 100644 --- a/crates/tranquil-pds/src/api/repo/blob.rs +++ b/crates/tranquil-pds/src/api/repo/blob.rs @@ -56,8 +56,8 @@ pub async fn upload_blob( if user.status.is_takendown() { return Err(ApiError::AccountTakedown); } - let mime_type_for_check = - get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); + let mime_type_for_check = get_header_str(&headers, http::header::CONTENT_TYPE) + .unwrap_or("application/octet-stream"); let scope_proof = match user.verify_blob_upload(mime_type_for_check) { Ok(proof) => proof, Err(e) => return Ok(e.into_response()), @@ -79,7 +79,7 @@ pub async fn upload_blob( } let client_mime_hint = - get_header_str(&headers, "content-type").unwrap_or("application/octet-stream"); + get_header_str(&headers, http::header::CONTENT_TYPE).unwrap_or("application/octet-stream"); let user_id = state .user_repo @@ -89,7 +89,7 @@ pub async fn upload_blob( .ok_or(ApiError::InternalError(None))?; let temp_key = format!("temp/{}", uuid::Uuid::new_v4()); - let max_size = get_max_blob_size() as u64; + let max_size = u64::try_from(get_max_blob_size()).unwrap_or(u64::MAX); let body_stream = body.into_data_stream(); let mapped_stream = @@ -148,7 +148,13 @@ pub async fn upload_blob( match state .blob_repo - .insert_blob(&cid_link, &mime_type, size as i64, user_id, &storage_key) + .insert_blob( + &cid_link, + &mime_type, + i64::try_from(size).unwrap_or(i64::MAX), + user_id, + &storage_key, + ) .await { Ok(_) => {} @@ -248,10 +254,11 @@ pub async fn list_missing_blobs( .await .log_db_err("fetching missing blobs")?; - let has_more = missing.len() > limit as usize; + let limit_usize = usize::try_from(limit).unwrap_or(0); + let has_more = missing.len() > limit_usize; let blobs: Vec = missing .into_iter() - .take(limit as usize) + .take(limit_usize) .map(|m| RecordBlob { cid: m.blob_cid.to_string(), record_uri: m.record_uri.to_string(), diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index e1a4955..01d3ee6 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -92,8 +92,12 @@ pub async fn import_repo( "Root block not found in CAR file".into(), )); }; - let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) { - Ok(commit) => commit.did().to_string(), + let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) { + Ok(commit) => commit + .did() + .as_str() + .parse() + .map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?, Err(e) => { return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e))); } @@ -104,9 +108,7 @@ pub async fn import_repo( commit_did, did ))); } - let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let skip_verification = crate::util::parse_env_bool("SKIP_IMPORT_VERIFICATION"); let is_migration = user.deactivated_at.is_some(); if skip_verification { warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)"); @@ -221,10 +223,14 @@ pub async fn import_repo( .flat_map(|record| { let record_uri = AtUri::from_parts(did.as_str(), &record.collection, &record.rkey); - record.blob_refs.iter().map(move |blob_ref| { - (record_uri.clone(), unsafe { - CidLink::new_unchecked(blob_ref.cid.clone()) - }) + record.blob_refs.iter().filter_map(move |blob_ref| { + match CidLink::new(&blob_ref.cid) { + Ok(cid_link) => Some((record_uri.clone(), cid_link)), + Err(_) => { + tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID reference during import"); + None + } + } }) }) .collect(); @@ -289,7 +295,7 @@ pub async fn import_repo( error!("Failed to store new commit block: {:?}", e); ApiError::InternalError(None) })?; - let new_root_cid_link = unsafe { CidLink::new_unchecked(new_root_cid.to_string()) }; + let new_root_cid_link = CidLink::from(&new_root_cid); state .repo_repo .update_repo_root(user_id, &new_root_cid_link, &new_rev_str) @@ -313,7 +319,8 @@ pub async fn import_repo( "Created new commit for imported repo: cid={}, rev={}", new_root_str, new_rev_str ); - if !is_migration && let Err(e) = sequence_import_event(&state, did, &new_root_str).await + if !is_migration + && let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await { warn!("Failed to sequence import event: {:?}", e); } @@ -378,12 +385,12 @@ pub async fn import_repo( async fn sequence_import_event( state: &AppState, did: &Did, - commit_cid: &str, + commit_cid: &CidLink, ) -> Result<(), tranquil_db::DbError> { let data = tranquil_db::CommitEventData { did: did.clone(), event_type: tranquil_db::RepoEventType::Commit, - commit_cid: Some(unsafe { CidLink::new_unchecked(commit_cid) }), + commit_cid: Some(commit_cid.clone()), prev_cid: None, ops: Some(serde_json::json!([])), blobs: Some(vec![]), diff --git a/crates/tranquil-pds/src/api/repo/record/batch.rs b/crates/tranquil-pds/src/api/repo/record/batch.rs index a2878b4..d6535a6 100644 --- a/crates/tranquil-pds/src/api/repo/record/batch.rs +++ b/crates/tranquil-pds/src/api/repo/record/batch.rs @@ -11,6 +11,7 @@ use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey}; +use crate::validation::ValidationStatus; use axum::{ Json, extract::State, @@ -23,7 +24,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; use std::sync::Arc; -use tracing::{error, info}; +use tracing::info; const MAX_BATCH_WRITES: usize = 200; @@ -87,7 +88,7 @@ async fn process_single_write( results.push(WriteResult::CreateResult { uri, cid: record_cid.to_string(), - validation_status: validation_status.map(|s| s.to_string()), + validation_status, }); ops.push(RecordOp::Create { collection: collection.clone(), @@ -138,7 +139,7 @@ async fn process_single_write( results.push(WriteResult::UpdateResult { uri, cid: record_cid.to_string(), - validation_status: validation_status.map(|s| s.to_string()), + validation_status, }); ops.push(RecordOp::Update { collection: collection.clone(), @@ -237,14 +238,14 @@ pub enum WriteResult { uri: AtUri, cid: String, #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] - validation_status: Option, + validation_status: Option, }, #[serde(rename = "com.atproto.repo.applyWrites#updateResult")] UpdateResult { uri: AtUri, cid: String, #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] - validation_status: Option, + validation_status: Option, }, #[serde(rename = "com.atproto.repo.applyWrites#deleteResult")] DeleteResult {}, @@ -441,15 +442,7 @@ pub async fn apply_writes( .await { Ok(res) => res, - Err(e) if e.contains("ConcurrentModification") => { - return Err(ApiError::InvalidSwap(Some("Repo has been modified".into()))); - } - Err(e) => { - error!("Commit failed: {}", e); - return Err(ApiError::InternalError(Some( - "Failed to commit changes".into(), - ))); - } + Err(e) => return Err(ApiError::from(e)), }; if let Some(ref controller) = controller_did { diff --git a/crates/tranquil-pds/src/api/repo/record/delete.rs b/crates/tranquil-pds/src/api/repo/record/delete.rs index c741975..36436ab 100644 --- a/crates/tranquil-pds/src/api/repo/record/delete.rs +++ b/crates/tranquil-pds/src/api/repo/record/delete.rs @@ -1,6 +1,6 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::{ - CommitParams, RecordOp, commit_and_log, get_current_root_cid, + CommitError, CommitParams, RecordOp, commit_and_log, get_current_root_cid, }; use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; use crate::auth::{Active, Auth, VerifyScope}; @@ -186,10 +186,7 @@ pub async fn delete_record( .await { Ok(res) => res, - Err(e) if e.contains("ConcurrentModification") => { - return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); - } - Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()), + Err(e) => return Ok(ApiError::from(e).into_response()), }; if let Some(ref controller) = controller_did { @@ -241,28 +238,30 @@ pub async fn delete_record_internal( user_id: Uuid, collection: &Nsid, rkey: &Rkey, -) -> Result<(), String> { +) -> Result<(), CommitError> { let _write_lock = state.repo_write_locks.lock(user_id).await; let root_cid_str = state .repo_repo .get_repo_root_cid_by_user_id(user_id) .await - .map_err(|e| format!("DB error: {}", e))? - .ok_or_else(|| "Repo root not found".to_string())?; + .map_err(|e| CommitError::DatabaseError(e.to_string()))? + .ok_or(CommitError::RepoNotFound)?; let current_root_cid = - Cid::from_str(root_cid_str.as_str()).map_err(|_| "Invalid repo root CID".to_string())?; + Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?; let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = tracking_store .get(¤t_root_cid) .await - .map_err(|e| format!("Failed to fetch commit: {:?}", e))? - .ok_or_else(|| "Commit block not found".to_string())?; + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? + .ok_or(CommitError::BlockStoreFailed( + "Commit block not found".into(), + ))?; - let commit = - Commit::from_cbor(&commit_bytes).map_err(|e| format!("Failed to parse commit: {:?}", e))?; + let commit = Commit::from_cbor(&commit_bytes) + .map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let key = format!("{}/{}", collection, rkey); @@ -270,7 +269,7 @@ pub async fn delete_record_internal( let prev_record_cid = mst .get(&key) .await - .map_err(|e| format!("MST get error: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let Some(prev_cid) = prev_record_cid else { return Ok(()); @@ -279,12 +278,12 @@ pub async fn delete_record_internal( let new_mst = mst .delete(&key) .await - .map_err(|e| format!("Failed to delete from MST: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let new_mst_root = new_mst .persist() .await - .map_err(|e| format!("Failed to persist MST: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let op = RecordOp::Delete { collection: collection.clone(), @@ -298,11 +297,11 @@ pub async fn delete_record_internal( new_mst .blocks_for_path(&key, &mut new_mst_blocks) .await - .map_err(|e| format!("Failed to get new MST blocks: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; mst.blocks_for_path(&key, &mut old_mst_blocks) .await - .map_err(|e| format!("Failed to get old MST blocks: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let mut relevant_blocks = new_mst_blocks.clone(); relevant_blocks.extend(old_mst_blocks.iter().map(|(k, v)| (*k, v.clone()))); diff --git a/crates/tranquil-pds/src/api/repo/record/read.rs b/crates/tranquil-pds/src/api/repo/record/read.rs index df39d8a..2bd9fa2 100644 --- a/crates/tranquil-pds/src/api/repo/record/read.rs +++ b/crates/tranquil-pds/src/api/repo/record/read.rs @@ -195,7 +195,7 @@ pub async fn list_records( } }; let limit = input.limit.unwrap_or(50).clamp(1, 100); - let limit_i64 = limit as i64; + let limit_i64 = i64::from(limit); let cursor_rkey = input .cursor .as_ref() diff --git a/crates/tranquil-pds/src/api/repo/record/utils.rs b/crates/tranquil-pds/src/api/repo/record/utils.rs index 2215c0f..ce22479 100644 --- a/crates/tranquil-pds/src/api/repo/record/utils.rs +++ b/crates/tranquil-pds/src/api/repo/record/utils.rs @@ -14,6 +14,71 @@ use tracing::error; use tranquil_db_traits::SequenceNumber; use uuid::Uuid; +#[derive(Debug)] +pub enum CommitError { + InvalidDid(String), + InvalidTid(String), + SigningFailed(String), + SerializationFailed(String), + KeyNotFound, + KeyDecryptionFailed(String), + InvalidKey(String), + BlockStoreFailed(String), + RepoNotFound, + ConcurrentModification, + DatabaseError(String), + UserNotFound, + CommitParseFailed(String), + MstOperationFailed(String), + RecordSerializationFailed(String), + InvalidCid(String), +} + +impl std::fmt::Display for CommitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidDid(e) => write!(f, "Invalid DID: {}", e), + Self::InvalidTid(e) => write!(f, "Invalid TID: {}", e), + Self::SigningFailed(e) => write!(f, "Failed to sign commit: {}", e), + Self::SerializationFailed(e) => write!(f, "Failed to serialize signed commit: {}", e), + Self::KeyNotFound => write!(f, "Signing key not found"), + Self::KeyDecryptionFailed(e) => write!(f, "Failed to decrypt signing key: {}", e), + Self::InvalidKey(e) => write!(f, "Invalid signing key: {}", e), + Self::BlockStoreFailed(e) => write!(f, "Block store operation failed: {}", e), + Self::RepoNotFound => write!(f, "Repo not found"), + Self::ConcurrentModification => { + write!(f, "Repo has been modified since last read") + } + Self::DatabaseError(e) => write!(f, "Database error: {}", e), + Self::UserNotFound => write!(f, "User not found"), + Self::CommitParseFailed(e) => write!(f, "Failed to parse commit: {}", e), + Self::MstOperationFailed(e) => write!(f, "MST operation failed: {}", e), + Self::RecordSerializationFailed(e) => { + write!(f, "Failed to serialize record: {}", e) + } + Self::InvalidCid(e) => write!(f, "Invalid CID: {}", e), + } + } +} + +impl std::error::Error for CommitError {} + +impl From for ApiError { + fn from(err: CommitError) -> Self { + match err { + CommitError::ConcurrentModification => { + ApiError::InvalidSwap(Some("Repo has been modified".into())) + } + CommitError::RepoNotFound => ApiError::RepoNotFound(None), + CommitError::UserNotFound => ApiError::RepoNotFound(Some("User not found".into())), + other => { + error!("Commit failed: {}", other); + ApiError::InternalError(Some("Failed to commit changes".into())) + } + } + } +} + pub async fn get_current_root_cid(state: &AppState, user_id: Uuid) -> Result { let root_cid_str = state .repo_repo @@ -55,7 +120,7 @@ fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec) { } use crate::types::AtUri; -use tranquil_db_traits::Backlink; +use tranquil_db_traits::{Backlink, BacklinkPath}; pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec { let record_type = record @@ -71,7 +136,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec { .map(|subject| { vec![Backlink { uri: uri.clone(), - path: "subject".to_string(), + path: BacklinkPath::Subject, link_to: subject.to_string(), }] }) @@ -84,7 +149,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec { .map(|subject_uri| { vec![Backlink { uri: uri.clone(), - path: "subject.uri".to_string(), + path: BacklinkPath::SubjectUri, link_to: subject_uri.to_string(), }] }) @@ -99,19 +164,19 @@ pub fn create_signed_commit( rev: &str, prev: Option, signing_key: &SigningKey, -) -> Result<(Vec, Bytes), String> { +) -> Result<(Vec, Bytes), CommitError> { let did = jacquard_common::types::string::Did::new(did.as_str()) - .map_err(|e| format!("Invalid DID: {:?}", e))?; + .map_err(|e| CommitError::InvalidDid(format!("{:?}", e)))?; let rev = jacquard_common::types::string::Tid::from_str(rev) - .map_err(|e| format!("Invalid TID: {:?}", e))?; + .map_err(|e| CommitError::InvalidTid(format!("{:?}", e)))?; let unsigned = Commit::new_unsigned(did, data, rev, prev); let signed = unsigned .sign(signing_key) - .map_err(|e| format!("Failed to sign commit: {:?}", e))?; + .map_err(|e| CommitError::SigningFailed(format!("{:?}", e)))?; let sig_bytes = signed.sig().clone(); let signed_bytes = signed .to_cbor() - .map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?; + .map_err(|e| CommitError::SerializationFailed(format!("{:?}", e)))?; Ok((signed_bytes, sig_bytes)) } @@ -154,7 +219,7 @@ pub struct CommitParams<'a> { pub async fn commit_and_log( state: &AppState, params: CommitParams<'_>, -) -> Result { +) -> Result { use tranquil_db_traits::{ ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, RepoEventType, @@ -175,12 +240,12 @@ pub async fn commit_and_log( .user_repo .get_user_key_by_id(user_id) .await - .map_err(|e| format!("Failed to fetch signing key: {}", e))? - .ok_or_else(|| "Signing key not found".to_string())?; + .map_err(|e| CommitError::DatabaseError(format!("Failed to fetch signing key: {}", e)))? + .ok_or(CommitError::KeyNotFound)?; let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) - .map_err(|e| format!("Failed to decrypt signing key: {}", e))?; + .map_err(|e| CommitError::KeyDecryptionFailed(e.to_string()))?; let signing_key = - SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?; + SigningKey::from_slice(&key_bytes).map_err(|e| CommitError::InvalidKey(e.to_string()))?; let rev = Tid::now(LimitedU32::MIN); let rev_str = rev.to_string(); let (new_commit_bytes, _sig) = @@ -189,7 +254,7 @@ pub async fn commit_and_log( .block_store .put(&new_commit_bytes) .await - .map_err(|e| format!("Failed to save commit block: {:?}", e))?; + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?; let mut all_block_cids: Vec> = blocks_cids .iter() @@ -218,7 +283,7 @@ pub async fn commit_and_log( upserts.push(RecordUpsert { collection: collection.clone(), rkey: rkey.clone(), - cid: unsafe { crate::types::CidLink::new_unchecked(cid.to_string()) }, + cid: crate::types::CidLink::from(cid), }); } RecordOp::Delete { @@ -283,23 +348,20 @@ pub async fn commit_and_log( let commit_event = CommitEventData { did: did.clone(), event_type: RepoEventType::Commit, - commit_cid: Some(unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }), - prev_cid: current_root_cid - .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), + commit_cid: Some(crate::types::CidLink::from(new_root_cid)), + prev_cid: current_root_cid.map(crate::types::CidLink::from), ops: Some(json!(ops_json)), blobs: Some(blobs.to_vec()), blocks_cids: Some(blocks_cids.to_vec()), - prev_data_cid: prev_data_cid - .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), + prev_data_cid: prev_data_cid.map(crate::types::CidLink::from), rev: Some(rev_str.clone()), }; let input = ApplyCommitInput { user_id, did: did.clone(), - expected_root_cid: current_root_cid - .map(|c| unsafe { crate::types::CidLink::new_unchecked(c.to_string()) }), - new_root_cid: unsafe { crate::types::CidLink::new_unchecked(new_root_cid.to_string()) }, + expected_root_cid: current_root_cid.map(crate::types::CidLink::from), + new_root_cid: crate::types::CidLink::from(new_root_cid), new_rev: rev_str.clone(), new_block_cids: all_block_cids, obsolete_block_cids: obsolete_bytes, @@ -313,11 +375,9 @@ pub async fn commit_and_log( .apply_commit(input) .await .map_err(|e| match e { - ApplyCommitError::RepoNotFound => "Repo not found".to_string(), - ApplyCommitError::ConcurrentModification => { - "ConcurrentModification: Repo has been modified since last read".to_string() - } - ApplyCommitError::Database(msg) => format!("DB Error: {}", msg), + ApplyCommitError::RepoNotFound => CommitError::RepoNotFound, + ApplyCommitError::ConcurrentModification => CommitError::ConcurrentModification, + ApplyCommitError::Database(msg) => CommitError::DatabaseError(msg), })?; if result.is_account_active { @@ -335,7 +395,7 @@ pub async fn create_record_internal( collection: &Nsid, rkey: &Rkey, record: &serde_json::Value, -) -> Result<(String, Cid), String> { +) -> Result<(String, Cid), CommitError> { use crate::repo::tracking::TrackingBlockStore; use jacquard_repo::mst::Mst; use std::sync::Arc; @@ -343,8 +403,8 @@ pub async fn create_record_internal( .user_repo .get_id_by_did(did) .await - .map_err(|e| format!("DB error: {}", e))? - .ok_or_else(|| "User not found".to_string())?; + .map_err(|e| CommitError::DatabaseError(e.to_string()))? + .ok_or(CommitError::UserNotFound)?; let _write_lock = state.repo_write_locks.lock(user_id).await; @@ -352,36 +412,38 @@ pub async fn create_record_internal( .repo_repo .get_repo_root_cid_by_user_id(user_id) .await - .map_err(|e| format!("DB error: {}", e))? - .ok_or_else(|| "Repo not found".to_string())?; - let current_root_cid = - Cid::from_str(root_cid_link.as_str()).map_err(|_| "Invalid repo root CID".to_string())?; + .map_err(|e| CommitError::DatabaseError(e.to_string()))? + .ok_or(CommitError::RepoNotFound)?; + let current_root_cid = Cid::from_str(root_cid_link.as_str()) + .map_err(|e| CommitError::InvalidCid(e.to_string()))?; let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = tracking_store .get(¤t_root_cid) .await - .map_err(|e| format!("Failed to fetch commit: {:?}", e))? - .ok_or_else(|| "Commit block not found".to_string())?; + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? + .ok_or(CommitError::BlockStoreFailed( + "Commit block not found".into(), + ))?; let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) - .map_err(|e| format!("Failed to parse commit: {:?}", e))?; + .map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let record_ipld = crate::util::json_to_ipld(record); let mut record_bytes = Vec::new(); serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) - .map_err(|e| format!("Failed to serialize record: {:?}", e))?; + .map_err(|e| CommitError::RecordSerializationFailed(format!("{:?}", e)))?; let record_cid = tracking_store .put(&record_bytes) .await - .map_err(|e| format!("Failed to save record block: {:?}", e))?; + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?; let key = format!("{}/{}", collection, rkey); let new_mst = mst .add(&key, record_cid) .await - .map_err(|e| format!("Failed to add to MST: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let new_mst_root = new_mst .persist() .await - .map_err(|e| format!("Failed to persist MST: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let op = RecordOp::Create { collection: collection.clone(), rkey: rkey.clone(), @@ -392,10 +454,10 @@ pub async fn create_record_internal( new_mst .blocks_for_path(&key, &mut new_mst_blocks) .await - .map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; mst.blocks_for_path(&key, &mut old_mst_blocks) .await - .map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?; + .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; let obsolete_cids: Vec = std::iter::once(current_root_cid) .chain( old_mst_blocks @@ -439,36 +501,38 @@ pub async fn sequence_identity_event( state: &AppState, did: &Did, handle: Option<&Handle>, -) -> Result { +) -> Result { state .repo_repo .insert_identity_event(did, handle) .await - .map_err(|e| format!("DB Error (identity event): {}", e)) + .map_err(|e| CommitError::DatabaseError(format!("identity event: {}", e))) } pub async fn sequence_account_event( state: &AppState, did: &Did, status: tranquil_db_traits::AccountStatus, -) -> Result { +) -> Result { state .repo_repo .insert_account_event(did, status) .await - .map_err(|e| format!("DB Error (account event): {}", e)) + .map_err(|e| CommitError::DatabaseError(format!("account event: {}", e))) } pub async fn sequence_sync_event( state: &AppState, did: &Did, commit_cid: &str, rev: Option<&str>, -) -> Result { - let cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid) }; +) -> Result { + let cid_link: crate::types::CidLink = commit_cid + .parse() + .map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?; state .repo_repo .insert_sync_event(did, &cid_link, rev) .await - .map_err(|e| format!("DB Error (sync event): {}", e)) + .map_err(|e| CommitError::DatabaseError(format!("sync event: {}", e))) } pub async fn sequence_genesis_commit( @@ -477,13 +541,12 @@ pub async fn sequence_genesis_commit( commit_cid: &Cid, mst_root_cid: &Cid, rev: &str, -) -> Result { - let commit_cid_link = unsafe { crate::types::CidLink::new_unchecked(commit_cid.to_string()) }; - let mst_root_cid_link = - unsafe { crate::types::CidLink::new_unchecked(mst_root_cid.to_string()) }; +) -> Result { + let commit_cid_link = crate::types::CidLink::from(commit_cid); + let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid); state .repo_repo .insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev) .await - .map_err(|e| format!("DB Error (genesis commit event): {}", e)) + .map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e))) } diff --git a/crates/tranquil-pds/src/api/repo/record/write.rs b/crates/tranquil-pds/src/api/repo/record/write.rs index d5e13c6..390b329 100644 --- a/crates/tranquil-pds/src/api/repo/record/write.rs +++ b/crates/tranquil-pds/src/api/repo/record/write.rs @@ -6,7 +6,7 @@ use crate::api::repo::record::utils::{ get_current_root_cid, }; use crate::auth::{ - Active, Auth, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated, + Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated, require_verified_or_delegated, }; use crate::cid_types::CommitCid; @@ -14,6 +14,7 @@ use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey}; +use crate::validation::ValidationStatus; use axum::{ Json, extract::State, @@ -32,7 +33,7 @@ use uuid::Uuid; pub struct RepoWriteAuth { pub did: Did, pub user_id: Uuid, - pub is_oauth: bool, + pub auth_source: AuthSource, pub scope: Option, pub controller_did: Option, } @@ -66,7 +67,7 @@ pub async fn prepare_repo_write( Ok(RepoWriteAuth { did: principal_did.into_did(), user_id, - is_oauth: user.is_oauth(), + auth_source: user.auth_source.clone(), scope: user.scope.clone(), controller_did: scope_proof.controller_did().map(|c| c.into_did()), }) @@ -97,7 +98,7 @@ pub struct CreateRecordOutput { pub cid: String, pub commit: CommitInfo, #[serde(skip_serializing_if = "Option::is_none")] - pub validation_status: Option, + pub validation_status: Option, } pub async fn create_record( State(state): State, @@ -323,10 +324,7 @@ pub async fn create_record( .await { Ok(res) => res, - Err(e) if e.contains("ConcurrentModification") => { - return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); - } - Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()), + Err(e) => return Ok(ApiError::from(e).into_response()), }; for conflict_uri in conflict_uris_to_cleanup { @@ -375,7 +373,7 @@ pub async fn create_record( cid: commit_result.commit_cid.to_string(), rev: commit_result.rev, }, - validation_status: validation_status.map(|s| s.to_string()), + validation_status, }), ) .into_response()) @@ -402,7 +400,7 @@ pub struct PutRecordOutput { #[serde(skip_serializing_if = "Option::is_none")] pub commit: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub validation_status: Option, + pub validation_status: Option, } pub async fn put_record( State(state): State, @@ -494,7 +492,7 @@ pub async fn put_record( uri: AtUri::from_parts(&did, &input.collection, &input.rkey), cid: record_cid.to_string(), commit: None, - validation_status: validation_status.map(|s| s.to_string()), + validation_status, }), ) .into_response()); @@ -600,10 +598,7 @@ pub async fn put_record( .await { Ok(res) => res, - Err(e) if e.contains("ConcurrentModification") => { - return Ok(ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response()); - } - Err(e) => return Ok(ApiError::InternalError(Some(e)).into_response()), + Err(e) => return Ok(ApiError::from(e).into_response()), }; if let Some(ref controller) = controller_did { @@ -634,7 +629,7 @@ pub async fn put_record( cid: commit_result.commit_cid.to_string(), rev: commit_result.rev, }), - validation_status: validation_status.map(|s| s.to_string()), + validation_status, }), ) .into_response()) diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 289fc27..9a1a267 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -285,7 +285,7 @@ async fn assert_valid_did_document_for_service( arr.iter().find(|svc| { svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds") || svc.get("type").and_then(|t| t.as_str()) - == Some("AtprotoPersonalDataServer") + == Some(crate::plc::ServiceType::Pds.as_str()) }) }) .and_then(|svc| svc.get("serviceEndpoint")) @@ -316,7 +316,7 @@ pub async fn activate_account( ); if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Repo, crate::oauth::scopes::AccountAction::Manage, @@ -366,10 +366,16 @@ pub async fn activate_account( did ); if let Some(ref h) = handle { - let _ = state.cache.delete(&format!("handle:{}", h)).await; + let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await; } - let _ = state.cache.delete(&format!("plc:doc:{}", did)).await; - let _ = state.cache.delete(&format!("plc:data:{}", did)).await; + let _ = state + .cache + .delete(&crate::cache_keys::plc_doc_key(&did)) + .await; + let _ = state + .cache + .delete(&crate::cache_keys::plc_data_key(&did)) + .await; if state.did_resolver.refresh_did(did.as_str()).await.is_none() { warn!( "[MIGRATION] activateAccount: Failed to refresh DID cache for {}", @@ -479,7 +485,7 @@ pub async fn deactivate_account( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Repo, crate::oauth::scopes::AccountAction::Manage, @@ -502,7 +508,7 @@ pub async fn deactivate_account( match result { Ok(true) => { if let Some(ref h) = handle { - let _ = state.cache.delete(&format!("handle:{}", h)).await; + let _ = state.cache.delete(&crate::cache_keys::handle_key(h)).await; } if let Err(e) = crate::api::repo::record::sequence_account_event( &state, @@ -659,7 +665,10 @@ pub async fn delete_account( ); } } - let _ = state.cache.delete(&format!("handle:{}", handle)).await; + let _ = state + .cache + .delete(&crate::cache_keys::handle_key(&handle)) + .await; info!("Account {} deleted successfully", did); EmptyResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/app_password.rs b/crates/tranquil-pds/src/api/server/app_password.rs index 93a9e70..89732aa 100644 --- a/crates/tranquil-pds/src/api/server/app_password.rs +++ b/crates/tranquil-pds/src/api/server/app_password.rs @@ -150,8 +150,9 @@ pub async fn create_app_password( ApiError::InternalError(None) })?; - let privilege = - tranquil_db_traits::AppPasswordPrivilege::from(input.privileged.unwrap_or(false)); + let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag( + input.privileged.unwrap_or(false), + ); let created_at = chrono::Utc::now(); let create_data = AppPasswordCreate { @@ -232,7 +233,7 @@ pub async fn revoke_app_password( .log_db_err("revoking sessions for app password")?; futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { - let cache_key = format!("auth:session:{}:{}", &auth.did, jti); + let cache_key = crate::cache_keys::session_key(&auth.did, jti); let cache = state.cache.clone(); async move { let _ = cache.delete(&cache_key).await; diff --git a/crates/tranquil-pds/src/api/server/email.rs b/crates/tranquil-pds/src/api/server/email.rs index f25f0f4..4034feb 100644 --- a/crates/tranquil-pds/src/api/server/email.rs +++ b/crates/tranquil-pds/src/api/server/email.rs @@ -21,7 +21,7 @@ use tranquil_db_traits::CommsChannel; const EMAIL_UPDATE_TTL: Duration = Duration::from_secs(30 * 60); fn email_update_cache_key(did: &str) -> String { - format!("email_update:{}", did) + crate::cache_keys::email_update_key(did) } fn hash_token(token: &str) -> String { @@ -51,7 +51,7 @@ pub async fn request_email_update( input: Option>, ) -> Result { if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Manage, @@ -111,7 +111,6 @@ pub async fn request_email_update( state.infra_repo.as_ref(), user.id, &token, - "email_update", hostname, ) .await @@ -138,7 +137,7 @@ pub async fn confirm_email( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Manage, @@ -173,13 +172,13 @@ pub async fn confirm_email( let verified = crate::auth::verification_token::verify_signup_token( &confirmation_code, - "email", + CommsChannel::Email, &provided_email, ); match verified { Ok(token_data) => { - if token_data.did != did.as_str() { + if token_data.did != *did { return Err(ApiError::InvalidToken(None)); } } @@ -216,7 +215,7 @@ pub async fn update_email( Json(input): Json, ) -> Result { if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Manage, @@ -324,13 +323,13 @@ pub async fn update_email( let verified = crate::auth::verification_token::verify_channel_update_token( &confirmation_token, - "email_update", + CommsChannel::Email, ¤t_email_lower, ); match verified { Ok(token_data) => { - if token_data.did != did.as_str() { + if token_data.did != *did { return Err(ApiError::InvalidToken(None)); } } @@ -361,8 +360,11 @@ pub async fn update_email( .await .log_db_err("updating email")?; - let verification_token = - crate::auth::verification_token::generate_signup_token(did, "email", &new_email); + let verification_token = crate::auth::verification_token::generate_signup_token( + did, + CommsChannel::Email, + &new_email, + ); let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); let hostname = pds_hostname(); @@ -370,7 +372,7 @@ pub async fn update_email( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, - "email", + tranquil_db_traits::CommsChannel::Email, &new_email, &formatted_token, hostname, @@ -422,8 +424,8 @@ pub async fn check_email_verified( #[derive(Deserialize)] pub struct CheckChannelVerifiedInput { - pub did: String, - pub channel: String, + pub did: crate::types::Did, + pub channel: CommsChannel, } pub async fn check_channel_verified( @@ -431,23 +433,9 @@ pub async fn check_channel_verified( _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let channel = match input.channel.to_lowercase().as_str() { - "email" => CommsChannel::Email, - "discord" => CommsChannel::Discord, - "telegram" => CommsChannel::Telegram, - "signal" => CommsChannel::Signal, - _ => { - return ApiError::InvalidRequest("invalid channel".into()).into_response(); - } - }; - - let did = match crate::Did::new(input.did) { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; match state .user_repo - .check_channel_verified_by_did(&did, channel) + .check_channel_verified_by_did(&input.did, input.channel) .await { Ok(Some(verified)) => VerifiedResponse::response(verified).into_response(), @@ -490,9 +478,9 @@ pub async fn authorize_email_update( ); return ApiError::InvalidToken(None).into_response(); } - if token_data.channel != "email_update" { + if token_data.channel != CommsChannel::Email { warn!( - "authorize_email_update: wrong channel: {}", + "authorize_email_update: wrong channel: {:?}", token_data.channel ); return ApiError::InvalidToken(None).into_response(); @@ -558,7 +546,7 @@ pub async fn check_email_update_status( auth: Auth, ) -> Result { if let Err(e) = crate::auth::scope_check::check_account_scope( - auth.is_oauth(), + &auth.auth_source, auth.scope.as_deref(), crate::oauth::scopes::AccountAttr::Email, crate::oauth::scopes::AccountAction::Read, @@ -620,7 +608,7 @@ pub async fn check_email_in_use( #[derive(Deserialize)] pub struct CheckCommsChannelInUseInput { - pub channel: String, + pub channel: CommsChannel, pub identifier: String, } @@ -629,16 +617,6 @@ pub async fn check_comms_channel_in_use( _rate_limit: RateLimited, Json(input): Json, ) -> Response { - let channel = match input.channel.to_lowercase().as_str() { - "email" => CommsChannel::Email, - "discord" => CommsChannel::Discord, - "telegram" => CommsChannel::Telegram, - "signal" => CommsChannel::Signal, - _ => { - return ApiError::InvalidRequest("invalid channel".into()).into_response(); - } - }; - let identifier = input.identifier.trim(); if identifier.is_empty() { return ApiError::InvalidRequest("identifier is required".into()).into_response(); @@ -646,7 +624,7 @@ pub async fn check_comms_channel_in_use( let count = match state .user_repo - .count_accounts_by_comms_identifier(channel, identifier) + .count_accounts_by_comms_identifier(input.channel, identifier) .await { Ok(c) => c, diff --git a/crates/tranquil-pds/src/api/server/invite.rs b/crates/tranquil-pds/src/api/server/invite.rs index 4319eaa..4f6c36b 100644 --- a/crates/tranquil-pds/src/api/server/invite.rs +++ b/crates/tranquil-pds/src/api/server/invite.rs @@ -226,7 +226,7 @@ pub async fn get_account_invite_codes( }) .unwrap_or_default(); - let use_count = uses.len() as i32; + let use_count = i32::try_from(uses.len()).unwrap_or(i32::MAX); if !include_used && use_count >= info.available_uses { return None; } diff --git a/crates/tranquil-pds/src/api/server/logo.rs b/crates/tranquil-pds/src/api/server/logo.rs index de50be5..037c13e 100644 --- a/crates/tranquil-pds/src/api/server/logo.rs +++ b/crates/tranquil-pds/src/api/server/logo.rs @@ -21,7 +21,10 @@ pub async fn get_logo(State(state): State) -> Response { Some(c) if !c.is_empty() => c, _ => return StatusCode::NOT_FOUND.into_response(), }; - let cid = unsafe { crate::types::CidLink::new_unchecked(&cid_str) }; + let cid = match crate::types::CidLink::new(&cid_str) { + Ok(c) => c, + Err(_) => return StatusCode::NOT_FOUND.into_response(), + }; let metadata = match state.blob_repo.get_blob_metadata(&cid).await { Ok(Some(m)) => m, @@ -38,7 +41,7 @@ pub async fn get_logo(State(state): State) -> Response { .header(header::CONTENT_TYPE, &metadata.mime_type) .header(header::CACHE_CONTROL, "public, max-age=3600") .body(Body::from(data)) - .unwrap(), + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()), Err(e) => { error!("Failed to fetch logo from storage: {:?}", e); StatusCode::NOT_FOUND.into_response() diff --git a/crates/tranquil-pds/src/api/server/meta.rs b/crates/tranquil-pds/src/api/server/meta.rs index 8ef9c87..bf1a58c 100644 --- a/crates/tranquil-pds/src/api/server/meta.rs +++ b/crates/tranquil-pds/src/api/server/meta.rs @@ -3,16 +3,17 @@ use crate::util::{discord_app_id, discord_bot_username, pds_hostname, telegram_b use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde_json::json; -fn get_available_comms_channels() -> Vec<&'static str> { - let mut channels = vec!["email"]; +fn get_available_comms_channels() -> Vec { + use tranquil_db_traits::CommsChannel; + let mut channels = vec![CommsChannel::Email]; if std::env::var("DISCORD_BOT_TOKEN").is_ok() { - channels.push("discord"); + channels.push(CommsChannel::Discord); } if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { - channels.push("telegram"); + channels.push(CommsChannel::Telegram); } if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { - channels.push("signal"); + channels.push(CommsChannel::Signal); } channels } @@ -35,9 +36,7 @@ pub async fn describe_server() -> impl IntoResponse { let domains_str = std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.to_string()); let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); - let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let invite_code_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); let contact_email = std::env::var("CONTACT_EMAIL").ok(); diff --git a/crates/tranquil-pds/src/api/server/migration.rs b/crates/tranquil-pds/src/api/server/migration.rs index 55e2f59..da295ae 100644 --- a/crates/tranquil-pds/src/api/server/migration.rs +++ b/crates/tranquil-pds/src/api/server/migration.rs @@ -196,7 +196,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_ })).collect::>(), "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] }); @@ -244,7 +244,7 @@ async fn build_did_document(state: &AppState, did: &crate::types::Did) -> serde_ }], "service": [{ "id": "#atproto_pds", - "type": "AtprotoPersonalDataServer", + "type": crate::plc::ServiceType::Pds.as_str(), "serviceEndpoint": service_endpoint }] }) diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index dcf18e8..412718b 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -16,13 +16,14 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; +use tranquil_db_traits::WebauthnChallengeType; use uuid::Uuid; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; use crate::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; use crate::state::AppState; -use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; +use crate::types::{Did, Handle, PlainPassword}; use crate::util::{pds_hostname, pds_hostname_without_port}; use crate::validation::validate_password; @@ -49,7 +50,7 @@ pub struct CreatePasskeyAccountInput { pub did: Option, pub did_type: Option, pub signing_key: Option, - pub verification_channel: Option, + pub verification_channel: Option, pub discord_username: Option, pub telegram_username: Option, pub signal_username: Option, @@ -73,7 +74,7 @@ pub async fn create_passkey_account( Json(input): Json, ) -> Response { let byod_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header( - crate::util::get_header_str(&headers, "Authorization"), + crate::util::get_header_str(&headers, http::header::AUTHORIZATION), ) { let token = extracted.token; if is_service_token(&token) { @@ -152,22 +153,22 @@ pub async fn create_passkey_account( Err(_) => return ApiError::InvalidInviteCode.into_response(), } } else { - let invite_required = std::env::var("INVITE_CODE_REQUIRED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); if invite_required { return ApiError::InviteCodeRequired.into_response(); } None }; - let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); + let verification_channel = input + .verification_channel + .unwrap_or(tranquil_db_traits::CommsChannel::Email); let verification_recipient = match verification_channel { - "email" => match &email { + tranquil_db_traits::CommsChannel::Email => match &email { Some(e) if !e.is_empty() => e.clone(), _ => return ApiError::MissingEmail.into_response(), }, - "discord" => match &input.discord_username { + tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().to_lowercase(); if !crate::api::validation::is_valid_discord_username(&clean) { @@ -179,7 +180,7 @@ pub async fn create_passkey_account( } _ => return ApiError::MissingDiscordId.into_response(), }, - "telegram" => match &input.telegram_username { + tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().trim_start_matches('@'); if !crate::api::validation::is_valid_telegram_username(clean) { @@ -191,13 +192,12 @@ pub async fn create_passkey_account( } _ => return ApiError::MissingTelegramUsername.into_response(), }, - "signal" => match &input.signal_username { + tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { Some(username) if !username.trim().is_empty() => { username.trim().trim_start_matches('@').to_lowercase() } _ => return ApiError::MissingSignalNumber.into_response(), }, - _ => return ApiError::InvalidVerificationChannel.into_response(), }; use k256::ecdsa::SigningKey; @@ -277,7 +277,7 @@ pub async fn create_passkey_account( ) .await { - return ApiError::InvalidDid(e).into_response(); + return ApiError::InvalidDid(e.to_string()).into_response(); } info!(did = %d, "Creating external did:web passkey account (reserved key)"); } @@ -380,7 +380,10 @@ pub async fn create_passkey_account( } }; let rev = Tid::now(LimitedU32::MIN); - let did_typed = unsafe { Did::new_unchecked(&did) }; + let did_typed: Did = match did.parse() { + Ok(d) => d, + Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), + }; let (commit_bytes, _sig) = match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) { Ok(result) => result, @@ -405,20 +408,15 @@ pub async fn create_passkey_account( }) }); - let preferred_comms_channel = match verification_channel { - "email" => tranquil_db_traits::CommsChannel::Email, - "discord" => tranquil_db_traits::CommsChannel::Discord, - "telegram" => tranquil_db_traits::CommsChannel::Telegram, - "signal" => tranquil_db_traits::CommsChannel::Signal, - _ => tranquil_db_traits::CommsChannel::Email, + let handle_typed: Handle = match handle.parse() { + Ok(h) => h, + Err(_) => return ApiError::InvalidHandle(None).into_response(), }; - - let handle_typed = unsafe { Handle::new_unchecked(&handle) }; let create_input = tranquil_db_traits::CreatePasskeyAccountInput { handle: handle_typed.clone(), email: email.clone().unwrap_or_default(), did: did_typed.clone(), - preferred_comms_channel, + preferred_comms_channel: verification_channel, discord_username: input .discord_username .as_deref() @@ -487,13 +485,11 @@ pub async fn create_passkey_account( "$type": "app.bsky.actor.profile", "displayName": handle }); - let profile_collection = unsafe { Nsid::new_unchecked("app.bsky.actor.profile") }; - let profile_rkey = unsafe { Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did_typed, - &profile_collection, - &profile_rkey, + &crate::types::PROFILE_COLLECTION, + &crate::types::PROFILE_RKEY, &profile_record, ) .await @@ -503,7 +499,7 @@ pub async fn create_passkey_account( } let verification_token = crate::auth::verification_token::generate_signup_token( - &did, + &did_typed, verification_channel, &verification_recipient, ); @@ -625,7 +621,7 @@ pub async fn complete_passkey_setup( let reg_state = match state .user_repo - .load_webauthn_challenge(&input.did, "registration") + .load_webauthn_challenge(&input.did, WebauthnChallengeType::Registration) .await { Ok(Some(json)) => match serde_json::from_str(&json) { @@ -706,7 +702,7 @@ pub async fn complete_passkey_setup( let _ = state .user_repo - .delete_webauthn_challenge(&input.did, "registration") + .delete_webauthn_challenge(&input.did, WebauthnChallengeType::Registration) .await; info!(did = %input.did, "Passkey-only account setup completed"); @@ -793,7 +789,7 @@ pub async fn start_passkey_registration_for_setup( }; if let Err(e) = state .user_repo - .save_webauthn_challenge(&input.did, "registration", &state_json) + .save_webauthn_challenge(&input.did, WebauthnChallengeType::Registration, &state_json) .await { error!("Failed to save registration state: {:?}", e); diff --git a/crates/tranquil-pds/src/api/server/passkeys.rs b/crates/tranquil-pds/src/api/server/passkeys.rs index 7113cc1..15272b3 100644 --- a/crates/tranquil-pds/src/api/server/passkeys.rs +++ b/crates/tranquil-pds/src/api/server/passkeys.rs @@ -9,6 +9,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; +use tranquil_db_traits::WebauthnChallengeType; use webauthn_rs::prelude::*; #[derive(Deserialize)] @@ -64,7 +65,7 @@ pub async fn start_passkey_registration( state .user_repo - .save_webauthn_challenge(&auth.did, "registration", &state_json) + .save_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration, &state_json) .await .log_db_err("saving registration state")?; @@ -98,7 +99,7 @@ pub async fn finish_passkey_registration( let reg_state_json = state .user_repo - .load_webauthn_challenge(&auth.did, "registration") + .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) .await .log_db_err("loading registration state")? .ok_or(ApiError::NoRegistrationInProgress)?; @@ -140,7 +141,7 @@ pub async fn finish_passkey_registration( if let Err(e) = state .user_repo - .delete_webauthn_challenge(&auth.did, "registration") + .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Registration) .await { warn!("Failed to delete registration state: {:?}", e); diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 2c4f09f..8d38b39 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -171,7 +171,7 @@ pub async fn reset_password( } }; futures::future::join_all(result.session_jtis.iter().map(|jti| { - let cache_key = format!("auth:session:{}:{}", result.did, jti); + let cache_key = crate::cache_keys::session_key(&result.did, jti); let cache = state.cache.clone(); async move { if let Err(e) = cache.delete(&cache_key).await { diff --git a/crates/tranquil-pds/src/api/server/reauth.rs b/crates/tranquil-pds/src/api/server/reauth.rs index 817b2a0..e61d7b9 100644 --- a/crates/tranquil-pds/src/api/server/reauth.rs +++ b/crates/tranquil-pds/src/api/server/reauth.rs @@ -8,7 +8,7 @@ use axum::{ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; -use tranquil_db_traits::{SessionRepository, UserRepository}; +use tranquil_db_traits::{SessionRepository, UserRepository, WebauthnChallengeType}; use crate::auth::{Active, Auth}; use crate::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message}; @@ -17,12 +17,20 @@ use crate::types::PlainPassword; pub const REAUTH_WINDOW_SECONDS: i64 = 300; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ReauthMethod { + Password, + Totp, + Passkey, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReauthStatusResponse { pub last_reauth_at: Option>, pub reauth_required: bool, - pub available_methods: Vec, + pub available_methods: Vec, } pub async fn get_reauth_status( @@ -180,7 +188,11 @@ pub async fn reauth_passkey_start( state .user_repo - .save_webauthn_challenge(&auth.did, "authentication", &state_json) + .save_webauthn_challenge( + &auth.did, + WebauthnChallengeType::Authentication, + &state_json, + ) .await .log_db_err("saving authentication state")?; @@ -201,7 +213,7 @@ pub async fn reauth_passkey_finish( ) -> Result { let auth_state_json = state .user_repo - .load_webauthn_challenge(&auth.did, "authentication") + .load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await .log_db_err("loading authentication state")? .ok_or(ApiError::NoChallengeInProgress)?; @@ -229,14 +241,17 @@ pub async fn reauth_passkey_finish( let cred_id_bytes = auth_result.cred_id().as_ref(); match state .user_repo - .update_passkey_counter(cred_id_bytes, auth_result.counter() as i32) + .update_passkey_counter( + cred_id_bytes, + i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), + ) .await { Ok(false) => { warn!(did = %&auth.did, "Passkey counter anomaly detected - possible cloned key"); let _ = state .user_repo - .delete_webauthn_challenge(&auth.did, "authentication") + .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await; return Err(ApiError::PasskeyCounterAnomaly); } @@ -248,7 +263,7 @@ pub async fn reauth_passkey_finish( let _ = state .user_repo - .delete_webauthn_challenge(&auth.did, "authentication") + .delete_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication) .await; let reauthed_at = update_last_reauth_cached(&*state.session_repo, &state.cache, &auth.did) @@ -265,12 +280,12 @@ pub async fn update_last_reauth_cached( did: &crate::types::Did, ) -> Result, tranquil_db_traits::DbError> { let now = session_repo.update_last_reauth(did).await?; - let cache_key = format!("reauth:{}", did); + let cache_key = crate::cache_keys::reauth_key(did); let _ = cache .set( &cache_key, &now.timestamp().to_string(), - std::time::Duration::from_secs(REAUTH_WINDOW_SECONDS as u64), + std::time::Duration::from_secs(u64::try_from(REAUTH_WINDOW_SECONDS).unwrap_or(300)), ) .await; Ok(now) @@ -290,7 +305,7 @@ async fn get_available_reauth_methods( user_repo: &dyn UserRepository, _session_repo: &dyn SessionRepository, did: &crate::types::Did, -) -> Vec { +) -> Vec { let mut methods = Vec::new(); let has_password = user_repo @@ -301,17 +316,17 @@ async fn get_available_reauth_methods( .is_some(); if has_password { - methods.push("password".to_string()); + methods.push(ReauthMethod::Password); } let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false); if has_totp { - methods.push("totp".to_string()); + methods.push(ReauthMethod::Totp); } let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false); if has_passkeys { - methods.push("passkey".to_string()); + methods.push(ReauthMethod::Passkey); } methods @@ -332,7 +347,7 @@ pub async fn check_reauth_required_cached( cache: &std::sync::Arc, did: &crate::types::Did, ) -> bool { - let cache_key = format!("reauth:{}", did); + let cache_key = crate::cache_keys::reauth_key(did); if let Some(timestamp_str) = cache.get(&cache_key).await && let Ok(timestamp) = timestamp_str.parse::() { @@ -355,7 +370,7 @@ pub async fn check_reauth_required_cached( pub struct ReauthRequiredError { pub error: String, pub message: String, - pub reauth_methods: Vec, + pub reauth_methods: Vec, } pub async fn reauth_required_response( @@ -428,5 +443,5 @@ pub async fn legacy_mfa_required_response( pub struct MfaVerificationRequiredError { pub error: String, pub message: String, - pub reauth_methods: Vec, + pub reauth_methods: Vec, } diff --git a/crates/tranquil-pds/src/api/server/service_auth.rs b/crates/tranquil-pds/src/api/server/service_auth.rs index d83bc97..83e5d33 100644 --- a/crates/tranquil-pds/src/api/server/service_auth.rs +++ b/crates/tranquil-pds/src/api/server/service_auth.rs @@ -2,6 +2,7 @@ use crate::AccountStatus; use crate::api::error::ApiError; use crate::state::AppState; use crate::types::Did; +use axum::http::Method; use axum::{ Json, extract::{Query, State}, @@ -10,34 +11,44 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::HashSet; +use std::sync::LazyLock; use tracing::{error, info, warn}; +use tranquil_types::Nsid; + +static CREATE_ACCOUNT_NSID: LazyLock = + LazyLock::new(|| "com.atproto.server.createAccount".parse().unwrap()); const HOUR_SECS: i64 = 3600; const MINUTE_SECS: i64 = 60; -const PROTECTED_METHODS: &[&str] = &[ - "com.atproto.admin.sendEmail", - "com.atproto.identity.requestPlcOperationSignature", - "com.atproto.identity.signPlcOperation", - "com.atproto.identity.updateHandle", - "com.atproto.server.activateAccount", - "com.atproto.server.confirmEmail", - "com.atproto.server.createAppPassword", - "com.atproto.server.deactivateAccount", - "com.atproto.server.getAccountInviteCodes", - "com.atproto.server.getSession", - "com.atproto.server.listAppPasswords", - "com.atproto.server.requestAccountDelete", - "com.atproto.server.requestEmailConfirmation", - "com.atproto.server.requestEmailUpdate", - "com.atproto.server.revokeAppPassword", - "com.atproto.server.updateEmail", -]; +static PROTECTED_METHODS: LazyLock> = LazyLock::new(|| { + [ + "com.atproto.admin.sendEmail", + "com.atproto.identity.requestPlcOperationSignature", + "com.atproto.identity.signPlcOperation", + "com.atproto.identity.updateHandle", + "com.atproto.server.activateAccount", + "com.atproto.server.confirmEmail", + "com.atproto.server.createAppPassword", + "com.atproto.server.deactivateAccount", + "com.atproto.server.getAccountInviteCodes", + "com.atproto.server.getSession", + "com.atproto.server.listAppPasswords", + "com.atproto.server.requestAccountDelete", + "com.atproto.server.requestEmailConfirmation", + "com.atproto.server.requestEmailUpdate", + "com.atproto.server.revokeAppPassword", + "com.atproto.server.updateEmail", + ] + .into_iter() + .collect() +}); #[derive(Deserialize)] pub struct GetServiceAuthParams { - pub aud: String, - pub lxm: Option, + pub aud: Did, + pub lxm: Option, pub exp: Option, } @@ -51,8 +62,8 @@ pub async fn get_service_auth( headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { - let auth_header = crate::util::get_header_str(&headers, "Authorization"); - let dpop_proof = crate::util::get_header_str(&headers, "DPoP"); + let auth_header = crate::util::get_header_str(&headers, axum::http::header::AUTHORIZATION); + let dpop_proof = crate::util::get_header_str(&headers, crate::util::HEADER_DPOP); info!( has_auth_header = auth_header.is_some(), has_dpop_proof = dpop_proof.is_some(), @@ -68,40 +79,47 @@ pub async fn get_service_auth( } }; - let (token, is_dpop) = if auth_header.len() >= 7 - && auth_header[..7].eq_ignore_ascii_case("bearer ") - { - (auth_header[7..].trim().to_string(), false) - } else if auth_header.len() >= 5 && auth_header[..5].eq_ignore_ascii_case("dpop ") { - (auth_header[5..].trim().to_string(), true) - } else { - warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme"); - return ApiError::AuthenticationRequired.into_response(); + let extracted = match crate::auth::extract_auth_token_from_header(Some(auth_header)) { + Some(e) => e, + None => { + warn!(auth_scheme = ?auth_header.split_whitespace().next(), "getServiceAuth: invalid auth scheme"); + return ApiError::AuthenticationRequired.into_response(); + } }; + let token = extracted.token; - let auth_user = if is_dpop { + let auth_user = if extracted.scheme.is_dpop() { match crate::oauth::verify::verify_oauth_access_token( state.oauth_repo.as_ref(), &token, dpop_proof, - "GET", + Method::GET.as_str(), &crate::util::build_full_url(&format!( "/xrpc/com.atproto.server.getServiceAuth?aud={}&lxm={}", params.aud, - params.lxm.as_deref().unwrap_or("") + params.lxm.as_ref().map_or("", |n| n.as_str()) )), ) .await { - Ok(result) => crate::auth::AuthenticatedUser { - did: unsafe { Did::new_unchecked(result.did) }, - is_admin: false, - status: AccountStatus::Active, - scope: result.scope, - key_bytes: None, - controller_did: None, - auth_source: crate::auth::AuthSource::OAuth, - }, + Ok(result) => { + let did: Did = match result.did.parse() { + Ok(d) => d, + Err(_) => { + return ApiError::InternalError(Some("Invalid DID in token".into())) + .into_response(); + } + }; + crate::auth::AuthenticatedUser { + did, + is_admin: false, + status: AccountStatus::Active, + scope: result.scope, + key_bytes: None, + controller_did: None, + auth_source: crate::auth::AuthSource::OAuth, + } + } Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => { return ( StatusCode::UNAUTHORIZED, @@ -179,15 +197,15 @@ pub async fn get_service_auth( } }; - let lxm = params.lxm.as_deref(); - let lxm_for_token = lxm.unwrap_or("*"); + let lxm = params.lxm.as_ref(); + let lxm_for_token = lxm.map_or("*", |n| n.as_str()); if let Some(method) = lxm { if let Err(e) = crate::auth::scope_check::check_rpc_scope( - auth_user.is_oauth(), + &auth_user.auth_source, auth_user.scope.as_deref(), - ¶ms.aud, - method, + params.aud.as_str(), + method.as_str(), ) { return e; } @@ -209,12 +227,12 @@ pub async fn get_service_auth( .flatten() .is_some_and(|s| s.takedown_ref.is_some()); - if is_takendown && lxm != Some("com.atproto.server.createAccount") { + if is_takendown && lxm != Some(&*CREATE_ACCOUNT_NSID) { return ApiError::InvalidToken(Some("Bad token scope".into())).into_response(); } if let Some(method) = lxm - && PROTECTED_METHODS.contains(&method) + && PROTECTED_METHODS.contains(&method.as_str()) { return ApiError::InvalidRequest(format!( "cannot request a service auth token for the following protected method: {}", @@ -248,7 +266,7 @@ pub async fn get_service_auth( let service_token = match crate::auth::create_service_token( &auth_user.did, - ¶ms.aud, + params.aud.as_str(), lxm_for_token, &key_bytes, ) { diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index 7d99f48..01dc2f9 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -266,7 +266,7 @@ pub async fn create_session( refresh_jti: refresh_meta.jti.clone(), access_expires_at: access_meta.expires_at, refresh_expires_at: refresh_meta.expires_at, - login_type: tranquil_db_traits::LoginType::from(is_legacy_login), + login_type: tranquil_db_traits::LoginType::from_legacy_flag(is_legacy_login), mfa_verified: false, scope: app_password_scopes.clone(), controller_did: app_password_controller.clone(), @@ -338,12 +338,6 @@ pub async fn get_session( ); match db_result { Ok(Some(row)) => { - let preferred_channel = match row.preferred_comms_channel { - tranquil_db_traits::CommsChannel::Email => "email", - tranquil_db_traits::CommsChannel::Discord => "discord", - tranquil_db_traits::CommsChannel::Telegram => "telegram", - tranquil_db_traits::CommsChannel::Signal => "signal", - }; let preferred_channel_verified = row .channel_verification .is_verified(row.preferred_comms_channel); @@ -365,7 +359,7 @@ pub async fn get_session( "handle": handle, "did": &auth.did, "active": account_state.is_active(), - "preferredChannel": preferred_channel, + "preferredChannel": row.preferred_comms_channel.as_str(), "preferredChannelVerified": preferred_channel_verified, "preferredLocale": row.preferred_locale, "isAdmin": row.is_admin @@ -404,7 +398,7 @@ pub async fn delete_session( ) -> Result { let extracted = crate::auth::extract_auth_token_from_header(crate::util::get_header_str( &headers, - "Authorization", + http::header::AUTHORIZATION, )) .ok_or(ApiError::AuthenticationRequired)?; let jti = crate::auth::get_jti_from_token(&extracted.token) @@ -413,7 +407,7 @@ pub async fn delete_session( match state.session_repo.delete_session_by_access_jti(&jti).await { Ok(rows) if rows > 0 => { if let Some(did) = did { - let session_cache_key = format!("auth:session:{}:{}", did, jti); + let session_cache_key = crate::cache_keys::session_key(&did, &jti); let _ = state.cache.delete(&session_cache_key).await; } Ok(EmptyResponse::ok().into_response()) @@ -430,7 +424,7 @@ pub async fn refresh_session( ) -> Response { let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( &headers, - "Authorization", + http::header::AUTHORIZATION, )) { Some(t) => t, None => return ApiError::AuthenticationRequired.into_response(), @@ -548,12 +542,6 @@ pub async fn refresh_session( ); match db_result { Ok(Some(u)) => { - let preferred_channel = match u.preferred_comms_channel { - tranquil_db_traits::CommsChannel::Email => "email", - tranquil_db_traits::CommsChannel::Discord => "discord", - tranquil_db_traits::CommsChannel::Telegram => "telegram", - tranquil_db_traits::CommsChannel::Signal => "signal", - }; let preferred_channel_verified = u .channel_verification .is_verified(u.preferred_comms_channel); @@ -568,7 +556,7 @@ pub async fn refresh_session( "did": session_row.did, "email": u.email, "emailConfirmed": u.channel_verification.email, - "preferredChannel": preferred_channel, + "preferredChannel": u.preferred_comms_channel.as_str(), "preferredChannelVerified": preferred_channel_verified, "preferredLocale": u.preferred_locale, "isAdmin": u.is_admin, @@ -609,7 +597,7 @@ pub struct ConfirmSignupOutput { pub did: Did, pub email: Option, pub email_verified: bool, - pub preferred_channel: String, + pub preferred_channel: tranquil_db_traits::CommsChannel, pub preferred_channel_verified: bool, } @@ -631,29 +619,26 @@ pub async fn confirm_signup( } }; - let (channel_str, identifier) = match row.channel { - tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), + let identifier = match row.channel { + tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), tranquil_db_traits::CommsChannel::Discord => { - ("discord", row.discord_username.clone().unwrap_or_default()) + row.discord_username.clone().unwrap_or_default() } - tranquil_db_traits::CommsChannel::Telegram => ( - "telegram", - row.telegram_username.clone().unwrap_or_default(), - ), - tranquil_db_traits::CommsChannel::Signal => { - ("signal", row.signal_username.clone().unwrap_or_default()) + tranquil_db_traits::CommsChannel::Telegram => { + row.telegram_username.clone().unwrap_or_default() } + tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), }; let normalized_token = crate::auth::verification_token::normalize_token_input(&input.verification_code); match crate::auth::verification_token::verify_signup_token( &normalized_token, - channel_str, + row.channel, &identifier, ) { Ok(token_data) => { - if token_data.did != input.did.as_str() { + if token_data.did != input.did { warn!( "Token DID mismatch for confirm_signup: expected {}, got {}", input.did, token_data.did @@ -733,21 +718,14 @@ pub async fn confirm_signup( { warn!("Failed to enqueue welcome notification: {:?}", e); } - let email_verified = matches!(row.channel, tranquil_db_traits::CommsChannel::Email); - let preferred_channel = match row.channel { - tranquil_db_traits::CommsChannel::Email => "email", - tranquil_db_traits::CommsChannel::Discord => "discord", - tranquil_db_traits::CommsChannel::Telegram => "telegram", - tranquil_db_traits::CommsChannel::Signal => "signal", - }; Json(ConfirmSignupOutput { access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, handle: row.handle, did: row.did, email: row.email, - email_verified, - preferred_channel: preferred_channel.to_string(), + email_verified: matches!(row.channel, tranquil_db_traits::CommsChannel::Email), + preferred_channel: row.channel, preferred_channel_verified: true, }) .into_response() @@ -783,22 +761,19 @@ pub async fn resend_verification( return ApiError::InvalidRequest("Account is already verified".into()).into_response(); } - let (channel_str, recipient) = match row.channel { - tranquil_db_traits::CommsChannel::Email => ("email", row.email.clone().unwrap_or_default()), + let recipient = match row.channel { + tranquil_db_traits::CommsChannel::Email => row.email.clone().unwrap_or_default(), tranquil_db_traits::CommsChannel::Discord => { - ("discord", row.discord_username.clone().unwrap_or_default()) + row.discord_username.clone().unwrap_or_default() } - tranquil_db_traits::CommsChannel::Telegram => ( - "telegram", - row.telegram_username.clone().unwrap_or_default(), - ), - tranquil_db_traits::CommsChannel::Signal => { - ("signal", row.signal_username.clone().unwrap_or_default()) + tranquil_db_traits::CommsChannel::Telegram => { + row.telegram_username.clone().unwrap_or_default() } + tranquil_db_traits::CommsChannel::Signal => row.signal_username.clone().unwrap_or_default(), }; let verification_token = - crate::auth::verification_token::generate_signup_token(&input.did, channel_str, &recipient); + crate::auth::verification_token::generate_signup_token(&input.did, row.channel, &recipient); let formatted_token = crate::auth::verification_token::format_token_for_display(&verification_token); @@ -807,7 +782,7 @@ pub async fn resend_verification( state.user_repo.as_ref(), state.infra_repo.as_ref(), row.id, - channel_str, + row.channel, &recipient, &formatted_token, hostname, @@ -819,11 +794,18 @@ pub async fn resend_verification( SuccessResponse::ok().into_response() } +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionType { + Legacy, + OAuth, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionInfo { pub id: String, - pub session_type: String, + pub session_type: SessionType, pub client_name: Option, pub created_at: String, pub expires_at: String, @@ -861,7 +843,7 @@ pub async fn list_sessions( let jwt_sessions = jwt_rows.into_iter().map(|row| SessionInfo { id: format!("jwt:{}", row.id), - session_type: "legacy".to_string(), + session_type: SessionType::Legacy, client_name: None, created_at: row.created_at.to_rfc3339(), expires_at: row.refresh_expires_at.to_rfc3339(), @@ -874,7 +856,7 @@ pub async fn list_sessions( let is_current_oauth = is_oauth && current_jti.as_deref() == Some(row.token_id.as_str()); SessionInfo { id: format!("oauth:{}", row.id), - session_type: "oauth".to_string(), + session_type: SessionType::OAuth, client_name: Some(client_name), created_at: row.created_at.to_rfc3339(), expires_at: row.expires_at.to_rfc3339(), @@ -925,7 +907,7 @@ pub async fn revoke_session( .delete_session_by_id(session_id) .await .log_db_err("deleting session")?; - let cache_key = format!("auth:session:{}:{}", &auth.did, access_jti); + let cache_key = crate::cache_keys::session_key(&auth.did, &access_jti); if let Err(e) = state.cache.delete(&cache_key).await { warn!("Failed to invalidate session cache: {:?}", e); } diff --git a/crates/tranquil-pds/src/api/server/signing_key.rs b/crates/tranquil-pds/src/api/server/signing_key.rs index 36407b9..fa5921e 100644 --- a/crates/tranquil-pds/src/api/server/signing_key.rs +++ b/crates/tranquil-pds/src/api/server/signing_key.rs @@ -25,7 +25,7 @@ fn public_key_to_did_key(signing_key: &SigningKey) -> String { #[derive(Deserialize)] pub struct ReserveSigningKeyInput { - pub did: Option, + pub did: Option, } #[derive(Serialize)] @@ -38,13 +38,6 @@ pub async fn reserve_signing_key( State(state): State, Json(input): Json, ) -> Response { - let did: Option = match input.did { - Some(ref d) => match d.parse() { - Ok(parsed) => Some(parsed), - Err(_) => return ApiError::InvalidDid("Invalid DID format".into()).into_response(), - }, - None => None, - }; let signing_key = SigningKey::random(&mut rand::thread_rng()); let private_key_bytes = signing_key.to_bytes(); let public_key_did_key = public_key_to_did_key(&signing_key); @@ -52,7 +45,12 @@ pub async fn reserve_signing_key( let private_bytes: &[u8] = &private_key_bytes; match state .infra_repo - .reserve_signing_key(did.as_ref(), &public_key_did_key, private_bytes, expires_at) + .reserve_signing_key( + input.did.as_ref(), + &public_key_did_key, + private_bytes, + expires_at, + ) .await { Ok(key_id) => { diff --git a/crates/tranquil-pds/src/api/server/trusted_devices.rs b/crates/tranquil-pds/src/api/server/trusted_devices.rs index cc1a806..e9e71ba 100644 --- a/crates/tranquil-pds/src/api/server/trusted_devices.rs +++ b/crates/tranquil-pds/src/api/server/trusted_devices.rs @@ -103,7 +103,7 @@ pub async fn list_trusted_devices( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RevokeTrustedDeviceInput { - pub device_id: String, + pub device_id: DeviceId, } pub async fn revoke_trusted_device( @@ -111,10 +111,9 @@ pub async fn revoke_trusted_device( auth: Auth, Json(input): Json, ) -> Result { - let device_id = DeviceId::from(input.device_id.clone()); match state .oauth_repo - .device_belongs_to_user(&device_id, &auth.did) + .device_belongs_to_user(&input.device_id, &auth.did) .await { Ok(true) => {} @@ -129,7 +128,7 @@ pub async fn revoke_trusted_device( state .oauth_repo - .revoke_device_trust(&device_id) + .revoke_device_trust(&input.device_id) .await .log_db_err("revoking device trust")?; @@ -140,7 +139,7 @@ pub async fn revoke_trusted_device( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateTrustedDeviceInput { - pub device_id: String, + pub device_id: DeviceId, pub friendly_name: Option, } @@ -149,10 +148,9 @@ pub async fn update_trusted_device( auth: Auth, Json(input): Json, ) -> Result { - let device_id = DeviceId::from(input.device_id.clone()); match state .oauth_repo - .device_belongs_to_user(&device_id, &auth.did) + .device_belongs_to_user(&input.device_id, &auth.did) .await { Ok(true) => {} @@ -167,7 +165,7 @@ pub async fn update_trusted_device( state .oauth_repo - .update_device_friendly_name(&device_id, input.friendly_name.as_deref()) + .update_device_friendly_name(&input.device_id, input.friendly_name.as_deref()) .await .log_db_err("updating device friendly name")?; @@ -177,14 +175,10 @@ pub async fn update_trusted_device( pub async fn get_device_trust_state( oauth_repo: &dyn OAuthRepository, - device_id: &str, + device_id: &DeviceId, did: &tranquil_types::Did, ) -> DeviceTrustState { - let device_id_typed = DeviceId::from(device_id.to_string()); - match oauth_repo - .get_device_trust_info(&device_id_typed, did) - .await - { + match oauth_repo.get_device_trust_info(device_id, did).await { Ok(Some(info)) => DeviceTrustState::from_timestamps(info.trusted_at, info.trusted_until), _ => DeviceTrustState::Untrusted, } @@ -192,7 +186,7 @@ pub async fn get_device_trust_state( pub async fn is_device_trusted( oauth_repo: &dyn OAuthRepository, - device_id: &str, + device_id: &DeviceId, did: &tranquil_types::Did, ) -> bool { get_device_trust_state(oauth_repo, device_id, did) @@ -202,23 +196,19 @@ pub async fn is_device_trusted( pub async fn trust_device( oauth_repo: &dyn OAuthRepository, - device_id: &str, + device_id: &DeviceId, ) -> Result<(), tranquil_db_traits::DbError> { let now = Utc::now(); let trusted_until = now + Duration::days(TRUST_DURATION_DAYS); - let device_id_typed = DeviceId::from(device_id.to_string()); - oauth_repo - .trust_device(&device_id_typed, now, trusted_until) - .await + oauth_repo.trust_device(device_id, now, trusted_until).await } pub async fn extend_device_trust( oauth_repo: &dyn OAuthRepository, - device_id: &str, + device_id: &DeviceId, ) -> Result<(), tranquil_db_traits::DbError> { let trusted_until = Utc::now() + Duration::days(TRUST_DURATION_DAYS); - let device_id_typed = DeviceId::from(device_id.to_string()); oauth_repo - .extend_device_trust(&device_id_typed, trusted_until) + .extend_device_trust(device_id, trusted_until) .await } diff --git a/crates/tranquil-pds/src/api/server/verify_token.rs b/crates/tranquil-pds/src/api/server/verify_token.rs index 57ed01d..f1237a2 100644 --- a/crates/tranquil-pds/src/api/server/verify_token.rs +++ b/crates/tranquil-pds/src/api/server/verify_token.rs @@ -10,6 +10,7 @@ use crate::auth::verification_token::{ VerificationPurpose, normalize_token_input, verify_token_signature, }; use crate::state::AppState; +use tranquil_db_traits::CommsChannel; #[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -23,8 +24,8 @@ pub struct VerifyTokenInput { pub struct VerifyTokenOutput { pub success: bool, pub did: Did, - pub purpose: String, - pub channel: String, + pub purpose: VerificationPurpose, + pub channel: CommsChannel, } pub async fn verify_token( @@ -53,14 +54,14 @@ pub async fn verify_token_internal( match token_data.purpose { VerificationPurpose::Migration => { - handle_migration_verification(state, &token_data.did, &token_data.channel, &identifier) + handle_migration_verification(state, &token_data.did, token_data.channel, &identifier) .await } VerificationPurpose::ChannelUpdate => { - handle_channel_update(state, &token_data.did, &token_data.channel, &identifier).await + handle_channel_update(state, &token_data.did, token_data.channel, &identifier).await } VerificationPurpose::Signup => { - handle_signup_verification(state, &token_data.did, &token_data.channel, &identifier) + handle_signup_verification(state, &token_data.did, token_data.channel, &identifier) .await } } @@ -68,20 +69,17 @@ pub async fn verify_token_internal( async fn handle_migration_verification( state: &AppState, - did: &str, - channel: &str, + did: &Did, + channel: CommsChannel, identifier: &str, ) -> Result, ApiError> { - if channel != "email" { + if channel != CommsChannel::Email { return Err(ApiError::InvalidChannel); } - let did_typed: Did = did - .parse() - .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; let user = state .user_repo - .get_verification_info(&did_typed) + .get_verification_info(did) .await .log_db_err("during migration verification")? .ok_or(ApiError::AccountNotFound)?; @@ -102,30 +100,27 @@ async fn handle_migration_verification( Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string().into(), - purpose: "migration".to_string(), - channel: channel.to_string(), + did: did.clone(), + purpose: VerificationPurpose::Migration, + channel, })) } async fn handle_channel_update( state: &AppState, - did: &str, - channel: &str, + did: &Did, + channel: CommsChannel, identifier: &str, ) -> Result, ApiError> { - let did_typed: Did = did - .parse() - .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; let user_id = state .user_repo - .get_id_by_did(&did_typed) + .get_id_by_did(did) .await .log_db_err("fetching user id")? .ok_or(ApiError::AccountNotFound)?; match channel { - "email" => { + CommsChannel::Email => { let success = state .user_repo .verify_email_channel(user_id, identifier) @@ -135,33 +130,30 @@ async fn handle_channel_update( return Err(ApiError::EmailTaken); } } - "discord" => { + CommsChannel::Discord => { state .user_repo .verify_discord_channel(user_id, identifier) .await .log_db_err("updating discord channel")?; } - "telegram" => { + CommsChannel::Telegram => { state .user_repo .verify_telegram_channel(user_id, identifier) .await .log_db_err("updating telegram channel")?; } - "signal" => { + CommsChannel::Signal => { state .user_repo .verify_signal_channel(user_id, identifier) .await .log_db_err("updating signal channel")?; } - _ => { - return Err(ApiError::InvalidChannel); - } }; - info!(did = %did, channel = %channel, "Channel verified successfully"); + info!(did = %did, channel = ?channel, "Channel verified successfully"); let recipient = resolve_verified_recipient(state, user_id, channel, identifier).await; if let Err(e) = comms_repo::enqueue_channel_verified( @@ -179,20 +171,20 @@ async fn handle_channel_update( Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string().into(), - purpose: "channel_update".to_string(), - channel: channel.to_string(), + did: did.clone(), + purpose: VerificationPurpose::ChannelUpdate, + channel, })) } async fn resolve_verified_recipient( state: &AppState, user_id: uuid::Uuid, - channel: &str, + channel: tranquil_db_traits::CommsChannel, identifier: &str, ) -> String { match channel { - "telegram" => state + tranquil_db_traits::CommsChannel::Telegram => state .user_repo .get_telegram_chat_id(user_id) .await @@ -206,16 +198,13 @@ async fn resolve_verified_recipient( async fn handle_signup_verification( state: &AppState, - did: &str, - channel: &str, + did: &Did, + channel: CommsChannel, identifier: &str, ) -> Result, ApiError> { - let did_typed: Did = did - .parse() - .map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?; let user = state .user_repo - .get_verification_info(&did_typed) + .get_verification_info(did) .await .log_db_err("during signup verification")? .ok_or(ApiError::AccountNotFound)?; @@ -225,47 +214,44 @@ async fn handle_signup_verification( info!(did = %did, "Account already verified"); return Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string().into(), - purpose: "signup".to_string(), - channel: channel.to_string(), + did: did.clone(), + purpose: VerificationPurpose::Signup, + channel, })); } match channel { - "email" => { + CommsChannel::Email => { state .user_repo .set_email_verified_flag(user.id) .await .log_db_err("updating email verified status")?; } - "discord" => { + CommsChannel::Discord => { state .user_repo .set_discord_verified_flag(user.id) .await .log_db_err("updating discord verified status")?; } - "telegram" => { + CommsChannel::Telegram => { state .user_repo .set_telegram_verified_flag(user.id) .await .log_db_err("updating telegram verified status")?; } - "signal" => { + CommsChannel::Signal => { state .user_repo .set_signal_verified_flag(user.id) .await .log_db_err("updating signal verified status")?; } - _ => { - return Err(ApiError::InvalidChannel); - } }; - info!(did = %did, channel = %channel, "Signup verified successfully"); + info!(did = %did, channel = ?channel, "Signup verified successfully"); let recipient = resolve_verified_recipient(state, user.id, channel, identifier).await; if let Err(e) = comms_repo::enqueue_channel_verified( @@ -283,8 +269,8 @@ async fn handle_signup_verification( Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string().into(), - purpose: "signup".to_string(), - channel: channel.to_string(), + did: did.clone(), + purpose: VerificationPurpose::Signup, + channel, })) } diff --git a/crates/tranquil-pds/src/api/telegram_webhook.rs b/crates/tranquil-pds/src/api/telegram_webhook.rs index dc84b4e..a4be92c 100644 --- a/crates/tranquil-pds/src/api/telegram_webhook.rs +++ b/crates/tranquil-pds/src/api/telegram_webhook.rs @@ -86,7 +86,7 @@ pub async fn handle_telegram_webhook( state.user_repo.as_ref(), state.infra_repo.as_ref(), user_id, - "telegram", + tranquil_db_traits::CommsChannel::Telegram, &from.id.to_string(), pds_hostname(), ) diff --git a/crates/tranquil-pds/src/api/temp.rs b/crates/tranquil-pds/src/api/temp.rs index c46e493..9887845 100644 --- a/crates/tranquil-pds/src/api/temp.rs +++ b/crates/tranquil-pds/src/api/temp.rs @@ -57,7 +57,7 @@ pub async fn dereference_scope( for part in scope_parts { if let Some(cid_str) = part.strip_prefix("ref:") { - let cache_key = format!("scope_ref:{}", cid_str); + let cache_key = crate::cache_keys::scope_ref_key(cid_str); if let Some(cached) = state.cache.get(&cache_key).await { for s in cached.split_whitespace() { if !resolved_scopes.contains(&s.to_string()) { diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index 8eb91df..2d0ae6d 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -23,7 +23,7 @@ impl ValidatedLocalHandle { } pub fn new_allow_reserved(handle: impl AsRef) -> Result { - let validated = validate_service_handle(handle.as_ref(), true)?; + let validated = validate_service_handle(handle.as_ref(), ReservedHandlePolicy::Allow)?; Ok(Self(validated)) } @@ -252,13 +252,19 @@ impl std::fmt::Display for HandleValidationError { impl std::error::Error for HandleValidationError {} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReservedHandlePolicy { + Allow, + Reject, +} + pub fn validate_short_handle(handle: &str) -> Result { - validate_service_handle(handle, false) + validate_service_handle(handle, ReservedHandlePolicy::Reject) } pub fn validate_service_handle( handle: &str, - allow_reserved: bool, + reserved_policy: ReservedHandlePolicy, ) -> Result { let handle = handle.trim(); @@ -301,7 +307,9 @@ pub fn validate_service_handle( return Err(HandleValidationError::BannedWord); } - if !allow_reserved && crate::handle::reserved::is_reserved_subdomain(handle) { + if reserved_policy == ReservedHandlePolicy::Reject + && crate::handle::reserved::is_reserved_subdomain(handle) + { return Err(HandleValidationError::Reserved); } @@ -501,12 +509,15 @@ mod tests { #[test] fn test_allow_reserved() { assert_eq!( - validate_service_handle("admin", true), + validate_service_handle("admin", ReservedHandlePolicy::Allow), Ok("admin".to_string()) ); - assert_eq!(validate_service_handle("api", true), Ok("api".to_string())); assert_eq!( - validate_service_handle("admin", false), + validate_service_handle("api", ReservedHandlePolicy::Allow), + Ok("api".to_string()) + ); + assert_eq!( + validate_service_handle("admin", ReservedHandlePolicy::Reject), Err(HandleValidationError::Reserved) ); } diff --git a/crates/tranquil-pds/src/api/verification.rs b/crates/tranquil-pds/src/api/verification.rs index 4b79107..e460326 100644 --- a/crates/tranquil-pds/src/api/verification.rs +++ b/crates/tranquil-pds/src/api/verification.rs @@ -10,7 +10,7 @@ use serde::Deserialize; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfirmChannelVerificationInput { - pub channel: String, + pub channel: tranquil_db_traits::CommsChannel, pub identifier: String, pub code: String, } diff --git a/crates/tranquil-pds/src/appview/mod.rs b/crates/tranquil-pds/src/appview/mod.rs index 750d8b2..0eae912 100644 --- a/crates/tranquil-pds/src/appview/mod.rs +++ b/crates/tranquil-pds/src/appview/mod.rs @@ -6,6 +6,18 @@ use std::time::{Duration, Instant}; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; +#[derive(Debug, thiserror::Error)] +pub enum DidResolutionError { + #[error("Invalid did:web format")] + InvalidDidWeb, + #[error("HTTP request failed: {0}")] + HttpFailed(String), + #[error("Invalid DID document: {0}")] + InvalidDocument(String), + #[error("DID not found")] + NotFound, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DidDocument { pub id: String, @@ -78,10 +90,10 @@ impl DidResolver { } } - fn build_did_web_url(did: &str) -> Result { + fn build_did_web_url(did: &str) -> Result { let host = did .strip_prefix("did:web:") - .ok_or("Invalid did:web format")?; + .ok_or(DidResolutionError::InvalidDidWeb)?; let (host, path) = if host.contains(':') { let decoded = host.replace("%3A", ":"); @@ -184,7 +196,7 @@ impl DidResolver { self.extract_service_endpoint(&doc) } - async fn resolve_did_web(&self, did: &str) -> Result { + async fn resolve_did_web(&self, did: &str) -> Result { let url = Self::build_did_web_url(did)?; debug!("Resolving did:web {} via {}", did, url); @@ -194,18 +206,21 @@ impl DidResolver { .get(&url) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); } resp.json::() .await - .map_err(|e| format!("Failed to parse DID document: {}", e)) + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) } - async fn resolve_did_plc(&self, did: &str) -> Result { + async fn resolve_did_plc(&self, did: &str) -> Result { let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); debug!("Resolving did:plc {} via {}", did, url); @@ -215,24 +230,27 @@ impl DidResolver { .get(&url) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err("DID not found".to_string()); + return Err(DidResolutionError::NotFound); } if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); } resp.json::() .await - .map_err(|e| format!("Failed to parse DID document: {}", e)) + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) } fn extract_service_endpoint(&self, doc: &DidDocument) -> Option { if let Some(service) = doc.service.iter().find(|s| { - s.service_type == "AtprotoAppView" + s.service_type == crate::plc::ServiceType::AppView.as_str() || s.id.contains("atproto_appview") || s.id.ends_with("#bsky_appview") }) { @@ -329,7 +347,10 @@ impl DidResolver { } } - async fn fetch_did_document_web(&self, did: &str) -> Result { + async fn fetch_did_document_web( + &self, + did: &str, + ) -> Result { let url = Self::build_did_web_url(did)?; let resp = self @@ -337,18 +358,24 @@ impl DidResolver { .get(&url) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); } resp.json::() .await - .map_err(|e| format!("Failed to parse DID document: {}", e)) + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) } - async fn fetch_did_document_plc(&self, did: &str) -> Result { + async fn fetch_did_document_plc( + &self, + did: &str, + ) -> Result { let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); let resp = self @@ -356,19 +383,22 @@ impl DidResolver { .get(&url) .send() .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + .map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?; if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err("DID not found".to_string()); + return Err(DidResolutionError::NotFound); } if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status())); + return Err(DidResolutionError::HttpFailed(format!( + "HTTP {}", + resp.status() + ))); } resp.json::() .await - .map_err(|e| format!("Failed to parse DID document: {}", e)) + .map_err(|e| DidResolutionError::InvalidDocument(e.to_string())) } pub async fn invalidate_cache(&self, did: &str) { diff --git a/crates/tranquil-pds/src/auth/email_token.rs b/crates/tranquil-pds/src/auth/email_token.rs index dfed756..20b5156 100644 --- a/crates/tranquil-pds/src/auth/email_token.rs +++ b/crates/tranquil-pds/src/auth/email_token.rs @@ -55,7 +55,7 @@ fn generate_short_token() -> String { } fn current_timestamp() -> u64 { - chrono::Utc::now().timestamp().max(0) as u64 + u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0) } pub async fn create_email_token( diff --git a/crates/tranquil-pds/src/auth/extractor.rs b/crates/tranquil-pds/src/auth/extractor.rs index 02cf737..415cf48 100644 --- a/crates/tranquil-pds/src/auth/extractor.rs +++ b/crates/tranquil-pds/src/auth/extractor.rs @@ -64,9 +64,21 @@ impl IntoResponse for AuthError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthScheme { + Bearer, + DPoP, +} + +impl AuthScheme { + pub fn is_dpop(self) -> bool { + matches!(self, Self::DPoP) + } +} + pub struct ExtractedToken { pub token: String, - pub is_dpop: bool, + pub scheme: AuthScheme, } pub fn extract_bearer_token_from_header(auth_header: Option<&str>) -> Option { @@ -100,7 +112,7 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option) -> Option Result { +async fn verify_service_token_claims(token: &str) -> Result { let verifier = ServiceTokenVerifier::new(); let claims = verifier .verify_service_token(token, None) @@ -289,11 +301,11 @@ async fn extract_auth_internal( extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; if is_service_token(&extracted.token) { - let claims = verify_service_token(&extracted.token).await?; + let claims = verify_service_token_claims(&extracted.token).await?; return Ok(ExtractedAuth::Service(claims)); } - let dpop_proof = crate::util::get_header_str(&parts.headers, "DPoP"); + let dpop_proof = crate::util::get_header_str(&parts.headers, crate::util::HEADER_DPOP); let method = parts.method.as_str(); let original_uri = parts .extensions @@ -418,7 +430,7 @@ pub struct ServiceAuth { impl ServiceAuth { pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> { match &self.claims.lxm { - Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()), + Some(lxm) if crate::auth::lxm_permits(lxm, expected_lxm) => Ok(()), Some(lxm) => Err(ApiError::AuthorizationError(format!( "Token lxm '{}' does not permit '{}'", lxm, expected_lxm diff --git a/crates/tranquil-pds/src/auth/legacy_2fa.rs b/crates/tranquil-pds/src/auth/legacy_2fa.rs index d6906d3..2f9aa88 100644 --- a/crates/tranquil-pds/src/auth/legacy_2fa.rs +++ b/crates/tranquil-pds/src/auth/legacy_2fa.rs @@ -135,7 +135,7 @@ fn generate_code() -> String { } fn current_timestamp() -> u64 { - Utc::now().timestamp().max(0) as u64 + u64::try_from(Utc::now().timestamp()).unwrap_or(0) } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index 3a0af7e..beeda7b 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -26,8 +26,9 @@ pub use login_identifier::{BareLoginIdentifier, NormalizedLoginIdentifier}; pub use account_verified::{AccountVerified, require_not_migrated, require_verified_or_delegated}; pub use extractor::{ - Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, - Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, + Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken, + NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header, + extract_bearer_token_from_header, }; pub use mfa_verified::{ MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window, @@ -39,12 +40,11 @@ pub use scope_verified::{ RpcCall, ScopeAction, ScopeVerificationError, ScopeVerified, VerifyScope, WriteOpKind, verify_batch_write_scopes, }; -pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; +pub use service::{ServiceTokenClaims, ServiceTokenError, ServiceTokenVerifier, is_service_token}; pub use tranquil_auth::{ - ActClaim, Claims, Header, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, - SCOPE_REFRESH, TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenData, - TokenVerifyError, TokenWithMetadata, UnsafeClaims, create_access_token, + ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType, + TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata, create_access_token_with_delegation, create_access_token_with_metadata, create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256, @@ -56,11 +56,18 @@ pub use tranquil_auth::{ verify_refresh_token, verify_refresh_token_hs256, verify_token, verify_totp_code, }; -pub fn encrypt_totp_secret(secret: &[u8]) -> Result, String> { +pub fn lxm_permits(lxm: &str, expected: &str) -> bool { + lxm == "*" || lxm == expected +} + +pub fn encrypt_totp_secret(secret: &[u8]) -> Result, crate::config::CryptoError> { crate::config::encrypt_key(secret) } -pub fn decrypt_totp_secret(encrypted: &[u8], version: i32) -> Result, String> { +pub fn decrypt_totp_secret( + encrypted: &[u8], + version: i32, +) -> Result, crate::config::CryptoError> { crate::config::decrypt_key(encrypted, Some(version)) } @@ -113,6 +120,7 @@ impl fmt::Display for TokenValidationError { } } +#[derive(Debug, Clone)] pub enum AuthSource { Session, OAuth, @@ -162,7 +170,7 @@ impl AuthenticatedUser { pub fn require_lxm(&self, expected_lxm: &str) -> Result<(), ApiError> { match self.auth_source.service_claims() { Some(claims) => match &claims.lxm { - Some(lxm) if lxm == "*" || lxm == expected_lxm => Ok(()), + Some(lxm) if lxm_permits(lxm, expected_lxm) => Ok(()), Some(lxm) => Err(ApiError::AuthorizationError(format!( "Token lxm '{}' does not permit '{}'", lxm, expected_lxm @@ -192,7 +200,7 @@ impl AuthenticatedUser { impl AuthenticatedUser { pub fn permissions(&self) -> ScopePermissions { if let Some(ref scope) = self.scope - && scope != SCOPE_ACCESS + && scope != TokenScope::Access.as_str() { return ScopePermissions::from_scope_string(Some(scope)); } @@ -265,7 +273,7 @@ async fn validate_bearer_token_with_options_internal( Ok(d) => d, Err(_) => return Err(TokenValidationError::InvalidToken), }; - let key_cache_key = format!("auth:key:{}", did_str); + let key_cache_key = crate::cache_keys::signing_key_key(did_str); let mut cached_key: Option> = None; if let Some(c) = cache { @@ -279,7 +287,7 @@ async fn validate_bearer_token_with_options_internal( let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key { - let status_cache_key = format!("auth:status:{}", did_str); + let status_cache_key = crate::cache_keys::user_status_key(did_str); let cached_status: Option = if let Some(c) = cache { c.get(&status_cache_key) .await @@ -347,7 +355,7 @@ async fn validate_bearer_token_with_options_internal( ) .await; - let status_cache_key = format!("auth:status:{}", did); + let status_cache_key = crate::cache_keys::user_status_key(&did.to_string()); let cached = CachedUserStatus { deactivated: user.deactivated_at.is_some(), takendown: user.takedown_ref.is_some(), @@ -386,7 +394,7 @@ async fn validate_bearer_token_with_options_internal( match verify_access_token_typed(token, &decrypted_key) { Ok(token_data) => { let jti = &token_data.claims.jti; - let session_cache_key = format!("auth:session:{}:{}", did, jti); + let session_cache_key = crate::cache_keys::session_key(&did, &jti); let mut session_valid = false; if let Some(c) = cache { @@ -424,11 +432,14 @@ async fn validate_bearer_token_with_options_internal( } if session_valid { - let controller_did = token_data - .claims - .act - .as_ref() - .map(|a| unsafe { Did::new_unchecked(a.sub.clone()) }); + let controller_did: Option = match &token_data.claims.act { + Some(act) => Some( + act.sub + .parse() + .map_err(|_| TokenValidationError::InvalidToken)?, + ), + None => None, + }; let status = AccountStatus::from_db_fields(takedown_ref.as_deref(), deactivated_at); return Ok(AuthenticatedUser { @@ -479,15 +490,22 @@ async fn validate_bearer_token_with_options_internal( } else { None }; + let did: Did = oauth_token + .did + .parse() + .map_err(|_| TokenValidationError::InvalidToken)?; + let controller_did: Option = oauth_info + .controller_did + .map(|d| d.parse()) + .transpose() + .map_err(|_| TokenValidationError::InvalidToken)?; return Ok(AuthenticatedUser { - did: unsafe { Did::new_unchecked(oauth_token.did) }, + did, key_bytes, is_admin: oauth_token.is_admin, status, scope: oauth_info.scope, - controller_did: oauth_info - .controller_did - .map(|d| unsafe { Did::new_unchecked(d) }), + controller_did, auth_source: AuthSource::OAuth, }); } else { @@ -499,33 +517,45 @@ async fn validate_bearer_token_with_options_internal( } pub async fn invalidate_auth_cache(cache: &dyn Cache, did: &str) { - let key_cache_key = format!("auth:key:{}", did); - let status_cache_key = format!("auth:status:{}", did); + let key_cache_key = crate::cache_keys::signing_key_key(did); + let status_cache_key = crate::cache_keys::user_status_key(did); let _ = cache.delete(&key_cache_key).await; let _ = cache.delete(&status_cache_key).await; } -#[allow(clippy::too_many_arguments)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountRequirement { + Active, + NotTakendown, + AnyStatus, +} + pub async fn validate_token_with_dpop( user_repo: &dyn UserRepository, oauth_repo: &dyn OAuthRepository, token: &str, - is_dpop_token: bool, + scheme: AuthScheme, dpop_proof: Option<&str>, http_method: &str, http_uri: &str, - allow_deactivated: bool, - allow_takendown: bool, + requirement: AccountRequirement, ) -> Result { - if !is_dpop_token { - if allow_takendown { - return validate_bearer_token_allow_takendown(user_repo, token).await; - } else if allow_deactivated { - return validate_bearer_token_allow_deactivated(user_repo, token).await; - } else { - return validate_bearer_token(user_repo, token).await; - } + if !scheme.is_dpop() { + return match requirement { + AccountRequirement::AnyStatus => { + validate_bearer_token_allow_takendown(user_repo, token).await + } + AccountRequirement::NotTakendown => { + validate_bearer_token_allow_deactivated(user_repo, token).await + } + AccountRequirement::Active => validate_bearer_token(user_repo, token).await, + }; } + let (allow_deactivated, allow_takendown) = match requirement { + AccountRequirement::Active => (false, false), + AccountRequirement::NotTakendown => (true, false), + AccountRequirement::AnyStatus => (true, true), + }; match crate::oauth::verify::verify_oauth_access_token( oauth_repo, token, @@ -566,7 +596,7 @@ pub async fn validate_token_with_dpop( None }; Ok(AuthenticatedUser { - did: unsafe { Did::new_unchecked(result.did) }, + did: result_did, key_bytes, is_admin: user_info.is_admin, status, @@ -581,3 +611,35 @@ pub async fn validate_token_with_dpop( Err(_) => Err(TokenValidationError::AuthenticationFailed), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lxm_permits_exact_match() { + assert!(lxm_permits( + "com.atproto.repo.uploadBlob", + "com.atproto.repo.uploadBlob" + )); + } + + #[test] + fn test_lxm_permits_wildcard() { + assert!(lxm_permits("*", "com.atproto.repo.uploadBlob")); + assert!(lxm_permits("*", "anything.at.all")); + } + + #[test] + fn test_lxm_permits_mismatch() { + assert!(!lxm_permits( + "com.atproto.repo.uploadBlob", + "com.atproto.repo.createRecord" + )); + } + + #[test] + fn test_lxm_permits_partial_not_wildcard() { + assert!(!lxm_permits("com.atproto.*", "com.atproto.repo.uploadBlob")); + } +} diff --git a/crates/tranquil-pds/src/auth/scope_check.rs b/crates/tranquil-pds/src/auth/scope_check.rs index bf560a5..445c214 100644 --- a/crates/tranquil-pds/src/auth/scope_check.rs +++ b/crates/tranquil-pds/src/auth/scope_check.rs @@ -7,22 +7,25 @@ use crate::oauth::scopes::{ AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, }; -use super::SCOPE_ACCESS; +use super::{AuthSource, TokenScope}; -fn has_custom_scope(scope: Option<&str>) -> bool { - match scope { - None => false, - Some(s) => s != SCOPE_ACCESS, +fn requires_scope_check(auth_source: &AuthSource, scope: Option<&str>) -> bool { + match auth_source { + AuthSource::OAuth => true, + _ => match scope { + None => false, + Some(s) => s != TokenScope::Access.as_str(), + }, } } pub fn check_repo_scope( - is_oauth: bool, + auth_source: &AuthSource, scope: Option<&str>, action: RepoAction, collection: &str, ) -> Result<(), Response> { - if !is_oauth && !has_custom_scope(scope) { + if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -32,8 +35,12 @@ pub fn check_repo_scope( .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } -pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { - if !is_oauth && !has_custom_scope(scope) { +pub fn check_blob_scope( + auth_source: &AuthSource, + scope: Option<&str>, + mime: &str, +) -> Result<(), Response> { + if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -44,12 +51,12 @@ pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Resu } pub fn check_rpc_scope( - is_oauth: bool, + auth_source: &AuthSource, scope: Option<&str>, aud: &str, lxm: &str, ) -> Result<(), Response> { - if !is_oauth && !has_custom_scope(scope) { + if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -60,12 +67,12 @@ pub fn check_rpc_scope( } pub fn check_account_scope( - is_oauth: bool, + auth_source: &AuthSource, scope: Option<&str>, attr: AccountAttr, action: AccountAction, ) -> Result<(), Response> { - if !is_oauth && !has_custom_scope(scope) { + if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -76,11 +83,11 @@ pub fn check_account_scope( } pub fn check_identity_scope( - is_oauth: bool, + auth_source: &AuthSource, scope: Option<&str>, attr: IdentityAttr, ) -> Result<(), Response> { - if !is_oauth && !has_custom_scope(scope) { + if !requires_scope_check(auth_source, scope) { return Ok(()); } diff --git a/crates/tranquil-pds/src/auth/service.rs b/crates/tranquil-pds/src/auth/service.rs index ce33eed..d66ffa6 100644 --- a/crates/tranquil-pds/src/auth/service.rs +++ b/crates/tranquil-pds/src/auth/service.rs @@ -1,6 +1,4 @@ -use crate::types::Did; use crate::util::pds_hostname; -use anyhow::{Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::Utc; @@ -9,6 +7,77 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::debug; +use tranquil_types::Did; + +#[derive(Debug, thiserror::Error)] +pub enum ServiceTokenError { + #[error("Invalid token format")] + InvalidFormat, + #[error("Base64 decode failed")] + Base64Decode(#[source] base64::DecodeError), + #[error("JSON decode failed")] + JsonDecode(#[source] serde_json::Error), + #[error("Unsupported algorithm: {0}")] + UnsupportedAlgorithm(super::SigningAlgorithm), + #[error("Token expired")] + Expired, + #[error("Invalid audience: expected {expected}, got {actual}")] + InvalidAudience { expected: Did, actual: Did }, + #[error("Token lxm '{token_lxm}' does not permit '{required}'")] + LxmMismatch { token_lxm: String, required: String }, + #[error("Token missing lxm claim")] + MissingLxm, + #[error("Invalid signature format")] + InvalidSignature(#[source] k256::ecdsa::Error), + #[error("Signature verification failed")] + SignatureVerificationFailed(#[source] k256::ecdsa::Error), + #[error("No atproto verification method found")] + NoVerificationMethod, + #[error("Verification method missing publicKeyMultibase")] + MissingPublicKey, + #[error("Unsupported DID method")] + UnsupportedDidMethod, + #[error("DID not found: {0}")] + DidNotFound(String), + #[error("HTTP request failed")] + HttpFailed(#[source] reqwest::Error), + #[error("Failed to parse DID document")] + InvalidDidDocument(#[source] reqwest::Error), + #[error("HTTP {0}")] + HttpStatus(reqwest::StatusCode), + #[error("Invalid multibase encoding")] + InvalidMultibase(#[source] multibase::Error), + #[error("Invalid multicodec data")] + InvalidMulticodec, + #[error("Unsupported key type: expected secp256k1")] + UnsupportedKeyType, + #[error("Invalid public key")] + InvalidPublicKey(#[source] k256::ecdsa::Error), +} + +struct JwtParts<'a> { + header: &'a str, + claims: &'a str, + signature: &'a str, +} + +impl<'a> JwtParts<'a> { + fn parse(token: &'a str) -> Result { + let mut parts = token.splitn(4, '.'); + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(header), Some(claims), Some(signature), None) => Ok(Self { + header, + claims, + signature, + }), + _ => Err(ServiceTokenError::InvalidFormat), + } + } + + fn signing_input(&self) -> String { + format!("{}.{}", self.header, self.claims) + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -48,9 +117,9 @@ pub struct ServiceTokenClaims { #[serde(default)] pub sub: Option, pub aud: Did, - pub exp: usize, + pub exp: i64, #[serde(default)] - pub iat: Option, + pub iat: Option, #[serde(skip_serializing_if = "Option::is_none")] pub lxm: Option, #[serde(default)] @@ -65,14 +134,14 @@ impl ServiceTokenClaims { #[derive(Debug, Clone, Serialize, Deserialize)] struct TokenHeader { - pub alg: String, - pub typ: String, + pub alg: super::SigningAlgorithm, + pub typ: super::TokenType, } pub struct ServiceTokenVerifier { client: Client, plc_directory_url: String, - pds_did: String, + pds_did: Did, } impl ServiceTokenVerifier { @@ -81,7 +150,9 @@ impl ServiceTokenVerifier { .unwrap_or_else(|_| "https://plc.directory".to_string()); let pds_hostname = pds_hostname(); - let pds_did = format!("did:web:{}", pds_hostname); + let pds_did: Did = format!("did:web:{}", pds_hostname) + .parse() + .expect("PDS hostname produces a valid DID"); let client = Client::builder() .timeout(Duration::from_secs(10)) @@ -102,55 +173,50 @@ impl ServiceTokenVerifier { &self, token: &str, required_lxm: Option<&str>, - ) -> Result { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err(anyhow!("Invalid token format")); - } + ) -> Result { + let jwt = JwtParts::parse(token)?; let header_bytes = URL_SAFE_NO_PAD - .decode(parts[0]) - .map_err(|e| anyhow!("Base64 decode of header failed: {}", e))?; + .decode(jwt.header) + .map_err(ServiceTokenError::Base64Decode)?; - let header: TokenHeader = serde_json::from_slice(&header_bytes) - .map_err(|e| anyhow!("JSON decode of header failed: {}", e))?; + let header: TokenHeader = + serde_json::from_slice(&header_bytes).map_err(ServiceTokenError::JsonDecode)?; - if header.alg != "ES256K" { - return Err(anyhow!("Unsupported algorithm: {}", header.alg)); + if header.alg != super::SigningAlgorithm::ES256K { + return Err(ServiceTokenError::UnsupportedAlgorithm(header.alg)); } let claims_bytes = URL_SAFE_NO_PAD - .decode(parts[1]) - .map_err(|e| anyhow!("Base64 decode of claims failed: {}", e))?; + .decode(jwt.claims) + .map_err(ServiceTokenError::Base64Decode)?; - let claims: ServiceTokenClaims = serde_json::from_slice(&claims_bytes) - .map_err(|e| anyhow!("JSON decode of claims failed: {}", e))?; + let claims: ServiceTokenClaims = + serde_json::from_slice(&claims_bytes).map_err(ServiceTokenError::JsonDecode)?; - let now = Utc::now().timestamp() as usize; + let now = Utc::now().timestamp(); if claims.exp < now { - return Err(anyhow!("Token expired")); + return Err(ServiceTokenError::Expired); } - if claims.aud.as_str() != self.pds_did { - return Err(anyhow!( - "Invalid audience: expected {}, got {}", - self.pds_did, - claims.aud - )); + if claims.aud != self.pds_did { + return Err(ServiceTokenError::InvalidAudience { + expected: self.pds_did.clone(), + actual: claims.aud.clone(), + }); } if let Some(required) = required_lxm { match &claims.lxm { - Some(lxm) if lxm == "*" || lxm == required => {} + Some(lxm) if crate::auth::lxm_permits(lxm, required) => {} Some(lxm) => { - return Err(anyhow!( - "Token lxm '{}' does not permit '{}'", - lxm, - required - )); + return Err(ServiceTokenError::LxmMismatch { + token_lxm: lxm.clone(), + required: required.to_string(), + }); } None => { - return Err(anyhow!("Token missing lxm claim")); + return Err(ServiceTokenError::MissingLxm); } } } @@ -159,51 +225,51 @@ impl ServiceTokenVerifier { let public_key = self.resolve_signing_key(did).await?; let signature_bytes = URL_SAFE_NO_PAD - .decode(parts[2]) - .map_err(|e| anyhow!("Base64 decode of signature failed: {}", e))?; + .decode(jwt.signature) + .map_err(ServiceTokenError::Base64Decode)?; - let signature = Signature::from_slice(&signature_bytes) - .map_err(|e| anyhow!("Invalid signature format: {}", e))?; + let signature = + Signature::from_slice(&signature_bytes).map_err(ServiceTokenError::InvalidSignature)?; - let message = format!("{}.{}", parts[0], parts[1]); + let message = jwt.signing_input(); public_key .verify(message.as_bytes(), &signature) - .map_err(|e| anyhow!("Signature verification failed: {}", e))?; + .map_err(ServiceTokenError::SignatureVerificationFailed)?; debug!("Service token verified for DID: {}", did); Ok(claims) } - async fn resolve_signing_key(&self, did: &str) -> Result { + async fn resolve_signing_key(&self, did: &str) -> Result { let did_doc = self.resolve_did_document(did).await?; let atproto_key = did_doc .verification_method .iter() .find(|vm| vm.id.ends_with("#atproto") || vm.id == format!("{}#atproto", did)) - .ok_or_else(|| anyhow!("No atproto verification method found in DID document"))?; + .ok_or(ServiceTokenError::NoVerificationMethod)?; let multibase = atproto_key .public_key_multibase .as_ref() - .ok_or_else(|| anyhow!("Verification method missing publicKeyMultibase"))?; + .ok_or(ServiceTokenError::MissingPublicKey)?; parse_did_key_multibase(multibase) } - async fn resolve_did_document(&self, did: &str) -> Result { + async fn resolve_did_document(&self, did: &str) -> Result { if did.starts_with("did:plc:") { self.resolve_did_plc(did).await } else if did.starts_with("did:web:") { self.resolve_did_web(did).await } else { - Err(anyhow!("Unsupported DID method: {}", did)) + Err(ServiceTokenError::UnsupportedDidMethod) } } - async fn resolve_did_plc(&self, did: &str) -> Result { + async fn resolve_did_plc(&self, did: &str) -> Result { let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did)); debug!("Resolving did:plc {} via {}", did, url); @@ -212,32 +278,32 @@ impl ServiceTokenVerifier { .get(&url) .send() .await - .map_err(|e| anyhow!("HTTP request failed: {}", e))?; + .map_err(ServiceTokenError::HttpFailed)?; if resp.status() == reqwest::StatusCode::NOT_FOUND { - return Err(anyhow!("DID not found: {}", did)); + return Err(ServiceTokenError::DidNotFound(did.to_string())); } if !resp.status().is_success() { - return Err(anyhow!("HTTP {}", resp.status())); + return Err(ServiceTokenError::HttpStatus(resp.status())); } resp.json::() .await - .map_err(|e| anyhow!("Failed to parse DID document: {}", e)) + .map_err(ServiceTokenError::InvalidDidDocument) } - async fn resolve_did_web(&self, did: &str) -> Result { + async fn resolve_did_web(&self, did: &str) -> Result { let host = did .strip_prefix("did:web:") - .ok_or_else(|| anyhow!("Invalid did:web format"))?; + .ok_or(ServiceTokenError::InvalidFormat)?; - let parts: Vec<&str> = host.split(':').collect(); - if parts.is_empty() { - return Err(anyhow!("Invalid did:web format - no host")); - } - - let host_part = parts[0].replace("%3A", ":"); + let mut host_parts = host.splitn(2, ':'); + let host_part = host_parts + .next() + .ok_or(ServiceTokenError::InvalidFormat)? + .replace("%3A", ":"); + let path_part = host_parts.next(); let scheme = if host_part.starts_with("localhost") || host_part.starts_with("127.0.0.1") @@ -248,11 +314,12 @@ impl ServiceTokenVerifier { "https" }; - let url = if parts.len() == 1 { - format!("{}://{}/.well-known/did.json", scheme, host_part) - } else { - let path = parts[1..].join("/"); - format!("{}://{}/{}/did.json", scheme, host_part, path) + let url = match path_part { + None => format!("{}://{}/.well-known/did.json", scheme, host_part), + Some(path) => { + let resolved_path = path.replace(':', "/"); + format!("{}://{}/{}/did.json", scheme, host_part, resolved_path) + } }; debug!("Resolving did:web {} via {}", did, url); @@ -262,15 +329,15 @@ impl ServiceTokenVerifier { .get(&url) .send() .await - .map_err(|e| anyhow!("HTTP request failed: {}", e))?; + .map_err(ServiceTokenError::HttpFailed)?; if !resp.status().is_success() { - return Err(anyhow!("HTTP {}", resp.status())); + return Err(ServiceTokenError::HttpStatus(resp.status())); } resp.json::() .await - .map_err(|e| anyhow!("Failed to parse DID document: {}", e)) + .map_err(ServiceTokenError::InvalidDidDocument) } } @@ -280,44 +347,35 @@ impl Default for ServiceTokenVerifier { } } -fn parse_did_key_multibase(multibase: &str) -> Result { +fn parse_did_key_multibase(multibase: &str) -> Result { if !multibase.starts_with('z') { - return Err(anyhow!( - "Expected base58btc multibase encoding (starts with 'z')" + let base_char = multibase.chars().next().unwrap_or('?'); + return Err(ServiceTokenError::InvalidMultibase( + multibase::Error::UnknownBase(base_char), )); } - let (_, decoded) = - multibase::decode(multibase).map_err(|e| anyhow!("Failed to decode multibase: {}", e))?; + let (_, decoded) = multibase::decode(multibase).map_err(ServiceTokenError::InvalidMultibase)?; if decoded.len() < 2 { - return Err(anyhow!("Invalid multicodec data")); + return Err(ServiceTokenError::InvalidMulticodec); } - let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 { - (0xe701u16, &decoded[2..]) + let key_bytes = if decoded.starts_with(&crate::plc::SECP256K1_MULTICODEC_PREFIX) { + &decoded[crate::plc::SECP256K1_MULTICODEC_PREFIX.len()..] } else { - return Err(anyhow!( - "Unsupported key type. Expected secp256k1 (0xe701), got {:02x}{:02x}", - decoded[0], - decoded[1] - )); + return Err(ServiceTokenError::UnsupportedKeyType); }; - if codec != 0xe701 { - return Err(anyhow!("Only secp256k1 keys are supported")); - } - - VerifyingKey::from_sec1_bytes(key_bytes).map_err(|e| anyhow!("Invalid public key: {}", e)) + VerifyingKey::from_sec1_bytes(key_bytes).map_err(ServiceTokenError::InvalidPublicKey) } pub fn is_service_token(token: &str) -> bool { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { + let Ok(jwt) = JwtParts::parse(token) else { return false; - } + }; - let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(parts[1]) else { + let Ok(claims_bytes) = URL_SAFE_NO_PAD.decode(jwt.claims) else { return false; }; @@ -377,4 +435,34 @@ mod tests { let result = parse_did_key_multibase(test_key); assert!(result.is_ok(), "Failed to parse valid multibase key"); } + + #[test] + fn test_jwt_parts_parse_valid() { + let jwt = JwtParts::parse("a.b.c").unwrap(); + assert_eq!(jwt.header, "a"); + assert_eq!(jwt.claims, "b"); + assert_eq!(jwt.signature, "c"); + } + + #[test] + fn test_jwt_parts_parse_too_few() { + assert!(matches!( + JwtParts::parse("a.b"), + Err(ServiceTokenError::InvalidFormat) + )); + } + + #[test] + fn test_jwt_parts_parse_too_many() { + assert!(matches!( + JwtParts::parse("a.b.c.d"), + Err(ServiceTokenError::InvalidFormat) + )); + } + + #[test] + fn test_jwt_parts_signing_input() { + let jwt = JwtParts::parse("header.claims.sig").unwrap(); + assert_eq!(jwt.signing_input(), "header.claims"); + } } diff --git a/crates/tranquil-pds/src/auth/verification_token.rs b/crates/tranquil-pds/src/auth/verification_token.rs index 5d8b5bf..8b32335 100644 --- a/crates/tranquil-pds/src/auth/verification_token.rs +++ b/crates/tranquil-pds/src/auth/verification_token.rs @@ -1,6 +1,8 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::Mac; use sha2::{Digest, Sha256}; +use tranquil_db_traits::CommsChannel; +use tranquil_types::Did; type HmacSha256 = hmac::Hmac; @@ -9,7 +11,8 @@ const DEFAULT_SIGNUP_EXPIRY_MINUTES: u64 = 30; const DEFAULT_MIGRATION_EXPIRY_HOURS: u64 = 48; const DEFAULT_CHANNEL_UPDATE_EXPIRY_MINUTES: u64 = 10; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] pub enum VerificationPurpose { Signup, Migration, @@ -25,15 +28,6 @@ impl VerificationPurpose { } } - fn from_str(s: &str) -> Option { - match s { - "signup" => Some(Self::Signup), - "migration" => Some(Self::Migration), - "channel_update" => Some(Self::ChannelUpdate), - _ => None, - } - } - fn default_expiry_seconds(&self) -> u64 { match self { Self::Signup => DEFAULT_SIGNUP_EXPIRY_MINUTES * 60, @@ -43,11 +37,24 @@ impl VerificationPurpose { } } +impl std::str::FromStr for VerificationPurpose { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "signup" => Ok(Self::Signup), + "migration" => Ok(Self::Migration), + "channel_update" => Ok(Self::ChannelUpdate), + _ => Err(()), + } + } +} + #[derive(Debug)] pub struct VerificationToken { - pub did: String, + pub did: Did, pub purpose: VerificationPurpose, - pub channel: String, + pub channel: CommsChannel, pub identifier_hash: String, pub expires_at: u64, } @@ -75,22 +82,27 @@ pub fn hash_identifier(identifier: &str) -> String { URL_SAFE_NO_PAD.encode(&result[..16]) } -pub fn generate_signup_token(did: &str, channel: &str, identifier: &str) -> String { +pub fn generate_signup_token(did: &Did, channel: CommsChannel, identifier: &str) -> String { generate_token(did, VerificationPurpose::Signup, channel, identifier) } -pub fn generate_migration_token(did: &str, email: &str) -> String { - generate_token(did, VerificationPurpose::Migration, "email", email) +pub fn generate_migration_token(did: &Did, email: &str) -> String { + generate_token( + did, + VerificationPurpose::Migration, + CommsChannel::Email, + email, + ) } -pub fn generate_channel_update_token(did: &str, channel: &str, identifier: &str) -> String { +pub fn generate_channel_update_token(did: &Did, channel: CommsChannel, identifier: &str) -> String { generate_token(did, VerificationPurpose::ChannelUpdate, channel, identifier) } pub fn generate_token( - did: &str, + did: &Did, purpose: VerificationPurpose, - channel: &str, + channel: CommsChannel, identifier: &str, ) -> String { generate_token_with_expiry( @@ -103,14 +115,15 @@ pub fn generate_token( } pub fn generate_token_with_expiry( - did: &str, + did: &Did, purpose: VerificationPurpose, - channel: &str, + channel: CommsChannel, identifier: &str, expiry_seconds: u64, ) -> String { let key = derive_verification_key(); let identifier_hash = hash_identifier(identifier); + let channel_str = channel.as_str(); let expires_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -121,7 +134,7 @@ pub fn generate_token_with_expiry( "{}|{}|{}|{}|{}", did, purpose.as_str(), - channel, + channel_str, identifier_hash, expires_at ); @@ -135,7 +148,7 @@ pub fn generate_token_with_expiry( TOKEN_VERSION, did, purpose.as_str(), - channel, + channel_str, identifier_hash, expires_at, signature @@ -170,7 +183,7 @@ impl std::fmt::Display for VerifyError { pub fn verify_signup_token( token: &str, - expected_channel: &str, + expected_channel: CommsChannel, expected_identifier: &str, ) -> Result { let parsed = verify_token_signature(token)?; @@ -195,7 +208,7 @@ pub fn verify_migration_token( if parsed.purpose != VerificationPurpose::Migration { return Err(VerifyError::PurposeMismatch); } - if parsed.channel != "email" { + if parsed.channel != CommsChannel::Email { return Err(VerifyError::ChannelMismatch); } let expected_hash = hash_identifier(expected_email); @@ -207,7 +220,7 @@ pub fn verify_migration_token( pub fn verify_channel_update_token( token: &str, - expected_channel: &str, + expected_channel: CommsChannel, expected_identifier: &str, ) -> Result { let parsed = verify_token_signature(token)?; @@ -226,10 +239,10 @@ pub fn verify_channel_update_token( pub fn verify_token_for_did( token: &str, - expected_did: &str, + expected_did: &Did, ) -> Result { let parsed = verify_token_signature(token)?; - if parsed.did != expected_did { + if parsed.did != *expected_did { return Err(VerifyError::IdentifierMismatch); } Ok(parsed) @@ -253,12 +266,17 @@ pub fn verify_token_signature(token: &str) -> Result Result::new_from_slice(&key).expect("HMAC key size is valid"); mac.update(payload.as_bytes()); @@ -286,10 +304,12 @@ pub fn verify_token_signature(token: &str) -> Result Result { + pub fn new(hostname: &str) -> Result { let rp_id = hostname.split(':').next().unwrap_or(hostname).to_string(); let rp_origin = Url::parse(&format!("https://{}", hostname)) - .map_err(|e| format!("Invalid origin URL: {}", e))?; + .map_err(|e| WebauthnError::InvalidOrigin(e.to_string()))?; let builder = WebauthnBuilder::new(&rp_id, &rp_origin) - .map_err(|e| format!("Failed to create WebAuthn builder: {}", e))? + .map_err(|e| WebauthnError::BuilderFailed(e.to_string()))? .rp_name("Tranquil PDS") .danger_set_user_presence_only_security_keys(true); let webauthn = builder .build() - .map_err(|e| format!("Failed to build WebAuthn: {}", e))?; + .map_err(|e| WebauthnError::BuilderFailed(e.to_string()))?; Ok(Self { webauthn }) } @@ -29,7 +41,7 @@ impl WebAuthnConfig { username: &str, display_name: &str, exclude_credentials: Vec, - ) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), String> { + ) -> Result<(CreationChallengeResponse, SecurityKeyRegistration), WebauthnError> { let user_unique_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, user_id.as_bytes()); self.webauthn @@ -45,35 +57,35 @@ impl WebAuthnConfig { None, None, ) - .map_err(|e| format!("Failed to start registration: {}", e)) + .map_err(|e| WebauthnError::RegistrationFailed(e.to_string())) } pub fn finish_registration( &self, reg: &RegisterPublicKeyCredential, state: &SecurityKeyRegistration, - ) -> Result { + ) -> Result { self.webauthn .finish_securitykey_registration(reg, state) - .map_err(|e| format!("Failed to finish registration: {}", e)) + .map_err(|e| WebauthnError::RegistrationFailed(e.to_string())) } pub fn start_authentication( &self, credentials: Vec, - ) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), String> { + ) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), WebauthnError> { self.webauthn .start_securitykey_authentication(&credentials) - .map_err(|e| format!("Failed to start authentication: {}", e)) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) } pub fn finish_authentication( &self, auth: &PublicKeyCredential, state: &SecurityKeyAuthentication, - ) -> Result { + ) -> Result { self.webauthn .finish_securitykey_authentication(auth, state) - .map_err(|e| format!("Failed to finish authentication: {}", e)) + .map_err(|e| WebauthnError::AuthenticationFailed(e.to_string())) } } diff --git a/crates/tranquil-pds/src/cache_keys.rs b/crates/tranquil-pds/src/cache_keys.rs new file mode 100644 index 0000000..a607bdd --- /dev/null +++ b/crates/tranquil-pds/src/cache_keys.rs @@ -0,0 +1,35 @@ +pub fn session_key(did: &str, jti: &str) -> String { + format!("auth:session:{}:{}", did, jti) +} + +pub fn signing_key_key(did: &str) -> String { + format!("auth:key:{}", did) +} + +pub fn user_status_key(did: &str) -> String { + format!("auth:status:{}", did) +} + +pub fn handle_key(handle: &str) -> String { + format!("handle:{}", handle) +} + +pub fn reauth_key(did: &str) -> String { + format!("reauth:{}", did) +} + +pub fn plc_doc_key(did: &str) -> String { + format!("plc:doc:{}", did) +} + +pub fn plc_data_key(did: &str) -> String { + format!("plc:data:{}", did) +} + +pub fn email_update_key(did: &str) -> String { + format!("email_update:{}", did) +} + +pub fn scope_ref_key(cid: &str) -> String { + format!("scope_ref:{}", cid) +} diff --git a/crates/tranquil-pds/src/circuit_breaker.rs b/crates/tranquil-pds/src/circuit_breaker.rs index 55aafd9..927dc38 100644 --- a/crates/tranquil-pds/src/circuit_breaker.rs +++ b/crates/tranquil-pds/src/circuit_breaker.rs @@ -1,3 +1,4 @@ +use std::num::{NonZeroU32, NonZeroU64}; use std::sync::Arc; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::time::Duration; @@ -24,15 +25,15 @@ pub struct CircuitBreaker { impl CircuitBreaker { pub fn new( name: &str, - failure_threshold: u32, - success_threshold: u32, - timeout_secs: u64, + failure_threshold: NonZeroU32, + success_threshold: NonZeroU32, + timeout_secs: NonZeroU64, ) -> Self { Self { name: name.to_string(), - failure_threshold, - success_threshold, - timeout: Duration::from_secs(timeout_secs), + failure_threshold: failure_threshold.get(), + success_threshold: success_threshold.get(), + timeout: Duration::from_secs(timeout_secs.get()), state: Arc::new(RwLock::new(CircuitState::Closed)), failure_count: AtomicU32::new(0), success_count: AtomicU32::new(0), @@ -49,10 +50,10 @@ impl CircuitBreaker { let last_failure = self.last_failure_time.load(Ordering::SeqCst); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); - if now - last_failure >= self.timeout.as_secs() { + if now.saturating_sub(last_failure) >= self.timeout.as_secs() { drop(state); let mut state = self.state.write().await; if *state == CircuitState::Open { @@ -100,7 +101,7 @@ impl CircuitBreaker { *state = CircuitState::Open; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); self.last_failure_time.store(now, Ordering::SeqCst); tracing::warn!( @@ -150,8 +151,18 @@ impl Default for CircuitBreakers { impl CircuitBreakers { pub fn new() -> Self { Self { - plc_directory: Arc::new(CircuitBreaker::new("plc_directory", 5, 3, 60)), - relay_notification: Arc::new(CircuitBreaker::new("relay_notification", 10, 5, 30)), + plc_directory: Arc::new(CircuitBreaker::new( + "plc_directory", + const { NonZeroU32::new(5).unwrap() }, + const { NonZeroU32::new(3).unwrap() }, + const { NonZeroU64::new(60).unwrap() }, + )), + relay_notification: Arc::new(CircuitBreaker::new( + "relay_notification", + const { NonZeroU32::new(10).unwrap() }, + const { NonZeroU32::new(5).unwrap() }, + const { NonZeroU64::new(30).unwrap() }, + )), } } } @@ -223,16 +234,21 @@ impl std::error::Error for CircuitBreakerError> = with_circuit_breaker(&cb, || async { Ok(42) }).await; diff --git a/crates/tranquil-pds/src/comms/mod.rs b/crates/tranquil-pds/src/comms/mod.rs index 5e874c8..ae5c899 100644 --- a/crates/tranquil-pds/src/comms/mod.rs +++ b/crates/tranquil-pds/src/comms/mod.rs @@ -7,4 +7,4 @@ pub use tranquil_comms::{ mime_encode_header, sanitize_header_value, validate_locale, }; -pub use service::{CommsService, channel_display_name, repo as comms_repo}; +pub use service::{CommsService, repo as comms_repo}; diff --git a/crates/tranquil-pds/src/comms/service.rs b/crates/tranquil-pds/src/comms/service.rs index d989368..fa5d315 100644 --- a/crates/tranquil-pds/src/comms/service.rs +++ b/crates/tranquil-pds/src/comms/service.rs @@ -7,8 +7,7 @@ use tokio::time::interval; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tranquil_comms::{ - CommsChannel, CommsSender, CommsStatus, CommsType, NewComms, SendError, format_message, - get_strings, + CommsChannel, CommsSender, CommsType, NewComms, SendError, format_message, get_strings, }; use tranquil_db_traits::{InfraRepository, QueuedComms, UserCommsPrefs, UserRepository}; use uuid::Uuid; @@ -54,35 +53,12 @@ impl CommsService { } pub async fn enqueue(&self, item: NewComms) -> Result { - let channel = match item.channel { - CommsChannel::Email => tranquil_db_traits::CommsChannel::Email, - CommsChannel::Discord => tranquil_db_traits::CommsChannel::Discord, - CommsChannel::Telegram => tranquil_db_traits::CommsChannel::Telegram, - CommsChannel::Signal => tranquil_db_traits::CommsChannel::Signal, - }; - let comms_type = match item.comms_type { - CommsType::Welcome => tranquil_db_traits::CommsType::Welcome, - CommsType::EmailVerification => tranquil_db_traits::CommsType::EmailVerification, - CommsType::PasswordReset => tranquil_db_traits::CommsType::PasswordReset, - CommsType::EmailUpdate => tranquil_db_traits::CommsType::EmailUpdate, - CommsType::AccountDeletion => tranquil_db_traits::CommsType::AccountDeletion, - CommsType::AdminEmail => tranquil_db_traits::CommsType::AdminEmail, - CommsType::PlcOperation => tranquil_db_traits::CommsType::PlcOperation, - CommsType::TwoFactorCode => tranquil_db_traits::CommsType::TwoFactorCode, - CommsType::PasskeyRecovery => tranquil_db_traits::CommsType::PasskeyRecovery, - CommsType::LegacyLoginAlert => tranquil_db_traits::CommsType::LegacyLoginAlert, - CommsType::MigrationVerification => { - tranquil_db_traits::CommsType::MigrationVerification - } - CommsType::ChannelVerification => tranquil_db_traits::CommsType::ChannelVerification, - CommsType::ChannelVerified => tranquil_db_traits::CommsType::ChannelVerified, - }; let id = self .infra_repo .enqueue_comms( Some(item.user_id), - channel, - comms_type, + item.channel, + item.comms_type, &item.recipient, item.subject.as_deref(), &item.body, @@ -144,62 +120,15 @@ impl CommsService { async fn process_item(&self, item: QueuedComms) { let comms_id = item.id; - let channel = match item.channel { - tranquil_db_traits::CommsChannel::Email => CommsChannel::Email, - tranquil_db_traits::CommsChannel::Discord => CommsChannel::Discord, - tranquil_db_traits::CommsChannel::Telegram => CommsChannel::Telegram, - tranquil_db_traits::CommsChannel::Signal => CommsChannel::Signal, - }; - let comms_item = tranquil_comms::QueuedComms { - id: item.id, - user_id: item.user_id, - channel, - comms_type: match item.comms_type { - tranquil_db_traits::CommsType::Welcome => CommsType::Welcome, - tranquil_db_traits::CommsType::EmailVerification => CommsType::EmailVerification, - tranquil_db_traits::CommsType::PasswordReset => CommsType::PasswordReset, - tranquil_db_traits::CommsType::EmailUpdate => CommsType::EmailUpdate, - tranquil_db_traits::CommsType::AccountDeletion => CommsType::AccountDeletion, - tranquil_db_traits::CommsType::AdminEmail => CommsType::AdminEmail, - tranquil_db_traits::CommsType::PlcOperation => CommsType::PlcOperation, - tranquil_db_traits::CommsType::TwoFactorCode => CommsType::TwoFactorCode, - tranquil_db_traits::CommsType::PasskeyRecovery => CommsType::PasskeyRecovery, - tranquil_db_traits::CommsType::LegacyLoginAlert => CommsType::LegacyLoginAlert, - tranquil_db_traits::CommsType::MigrationVerification => { - CommsType::MigrationVerification - } - tranquil_db_traits::CommsType::ChannelVerification => { - CommsType::ChannelVerification - } - tranquil_db_traits::CommsType::ChannelVerified => CommsType::ChannelVerified, - }, - status: match item.status { - tranquil_db_traits::CommsStatus::Pending => CommsStatus::Pending, - tranquil_db_traits::CommsStatus::Processing => CommsStatus::Processing, - tranquil_db_traits::CommsStatus::Sent => CommsStatus::Sent, - tranquil_db_traits::CommsStatus::Failed => CommsStatus::Failed, - }, - recipient: item.recipient, - subject: item.subject, - body: item.body, - metadata: item.metadata, - attempts: item.attempts, - max_attempts: item.max_attempts, - last_error: item.last_error, - created_at: item.created_at, - updated_at: item.updated_at, - scheduled_for: item.scheduled_for, - processed_at: item.processed_at, - }; - let result = match self.senders.get(&channel) { - Some(sender) => sender.send(&comms_item).await, + let result = match self.senders.get(&item.channel) { + Some(sender) => sender.send(&item).await, None => { warn!( comms_id = %comms_id, - channel = ?channel, + channel = ?item.channel, "No sender registered for channel" ); - Err(SendError::NotConfigured(channel)) + Err(SendError::NotConfigured(item.channel)) } }; match result { @@ -240,15 +169,6 @@ impl CommsService { } } -pub fn channel_display_name(channel: CommsChannel) -> &'static str { - match channel { - CommsChannel::Email => "email", - CommsChannel::Discord => "Discord", - CommsChannel::Telegram => "Telegram", - CommsChannel::Signal => "Signal", - } -} - struct ResolvedRecipient { channel: tranquil_db_traits::CommsChannel, recipient: String, @@ -292,15 +212,6 @@ fn resolve_recipient( } } -fn channel_from_str(s: &str) -> tranquil_db_traits::CommsChannel { - match s { - "discord" => tranquil_db_traits::CommsChannel::Discord, - "telegram" => tranquil_db_traits::CommsChannel::Telegram, - "signal" => tranquil_db_traits::CommsChannel::Signal, - _ => tranquil_db_traits::CommsChannel::Email, - } -} - pub mod repo { use super::*; use tranquil_db_traits::DbError; @@ -453,7 +364,6 @@ pub mod repo { infra_repo: &dyn InfraRepository, user_id: Uuid, token: &str, - purpose: &str, hostname: &str, ) -> Result { let prefs = user_repo @@ -463,18 +373,9 @@ pub mod repo { let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); let current_email = prefs.email.clone().unwrap_or_default(); - let (subject_template, body_template, comms_type) = match purpose { - "email_update" => ( - strings.email_update_subject, - strings.short_token_body, - CommsType::EmailUpdate, - ), - _ => ( - strings.email_update_subject, - strings.short_token_body, - CommsType::EmailUpdate, - ), - }; + let subject_template = strings.email_update_subject; + let body_template = strings.short_token_body; + let comms_type = CommsType::EmailUpdate; let verify_page = format!("https://{}/app/settings", hostname); let body = format_message( @@ -642,13 +543,19 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - channel: &str, + channel: tranquil_db_traits::CommsChannel, recipient: &str, code: &str, hostname: &str, ) -> Result { - let comms_channel = channel_from_str(channel); - let prefs = user_repo.get_comms_prefs(user_id).await.ok().flatten(); + let comms_channel = channel; + let prefs = match user_repo.get_comms_prefs(user_id).await { + Ok(p) => p, + Err(e) => { + tracing::warn!(user_id = %user_id, error = %e, "failed to fetch comms preferences, using defaults"); + None + } + }; let locale = prefs .as_ref() .and_then(|p| p.preferred_locale.as_deref()) @@ -762,7 +669,7 @@ pub mod repo { user_repo: &dyn UserRepository, infra_repo: &dyn InfraRepository, user_id: Uuid, - channel_name: &str, + channel: tranquil_db_traits::CommsChannel, recipient: &str, hostname: &str, ) -> Result { @@ -771,27 +678,19 @@ pub mod repo { .await? .ok_or(DbError::NotFound)?; let strings = get_strings(prefs.preferred_locale.as_deref().unwrap_or("en")); - let display_name = match channel_name { - "email" => "Email", - "discord" => "Discord", - "telegram" => "Telegram", - "signal" => "Signal", - other => other, - }; let body = format_message( strings.channel_verified_body, &[ ("handle", &prefs.handle), - ("channel", display_name), + ("channel", channel.display_name()), ("hostname", hostname), ], ); let subject = format_message(strings.channel_verified_subject, &[("hostname", hostname)]); - let comms_channel = channel_from_str(channel_name); infra_repo .enqueue_comms( Some(user_id), - comms_channel, + channel, CommsType::ChannelVerified, recipient, Some(&subject), diff --git a/crates/tranquil-pds/src/config.rs b/crates/tranquil-pds/src/config.rs index 578ba45..655e251 100644 --- a/crates/tranquil-pds/src/config.rs +++ b/crates/tranquil-pds/src/config.rs @@ -6,6 +6,29 @@ use p256::ecdsa::SigningKey; use sha2::{Digest, Sha256}; use std::sync::OnceLock; +#[derive(Debug)] +pub enum CryptoError { + CipherCreationFailed(String), + EncryptionFailed(String), + DecryptionFailed(String), + DataTooShort, + UnknownEncryptionVersion(i32), +} + +impl std::fmt::Display for CryptoError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CipherCreationFailed(e) => write!(f, "Failed to create cipher: {}", e), + Self::EncryptionFailed(e) => write!(f, "Encryption failed: {}", e), + Self::DecryptionFailed(e) => write!(f, "Decryption failed: {}", e), + Self::DataTooShort => write!(f, "Encrypted data too short"), + Self::UnknownEncryptionVersion(v) => write!(f, "Unknown encryption version: {}", v), + } + } +} + +impl std::error::Error for CryptoError {} + static CONFIG: OnceLock = OnceLock::new(); pub const ENCRYPTION_VERSION: i32 = 1; @@ -208,11 +231,11 @@ impl AuthConfig { } } - pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result, String> { + pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result, CryptoError> { use rand::RngCore; let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) - .map_err(|e| format!("Failed to create cipher: {}", e))?; + .map_err(|e| CryptoError::CipherCreationFailed(e.to_string()))?; let mut nonce_bytes = [0u8; 12]; rand::thread_rng().fill_bytes(&mut nonce_bytes); @@ -222,7 +245,7 @@ impl AuthConfig { let ciphertext = cipher .encrypt(nonce, plaintext) - .map_err(|e| format!("Encryption failed: {}", e))?; + .map_err(|e| CryptoError::EncryptionFailed(e.to_string()))?; let mut result = Vec::with_capacity(12 + ciphertext.len()); result.extend_from_slice(&nonce_bytes); @@ -231,13 +254,13 @@ impl AuthConfig { Ok(result) } - pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result, String> { + pub fn decrypt_user_key(&self, encrypted: &[u8]) -> Result, CryptoError> { if encrypted.len() < 12 { - return Err("Encrypted data too short".to_string()); + return Err(CryptoError::DataTooShort); } let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) - .map_err(|e| format!("Failed to create cipher: {}", e))?; + .map_err(|e| CryptoError::CipherCreationFailed(e.to_string()))?; #[allow(deprecated)] let nonce = Nonce::from_slice(&encrypted[..12]); @@ -245,18 +268,37 @@ impl AuthConfig { cipher .decrypt(nonce, ciphertext) - .map_err(|e| format!("Decryption failed: {}", e)) + .map_err(|e| CryptoError::DecryptionFailed(e.to_string())) } } -pub fn encrypt_key(plaintext: &[u8]) -> Result, String> { +pub fn encrypt_key(plaintext: &[u8]) -> Result, CryptoError> { AuthConfig::get().encrypt_user_key(plaintext) } -pub fn decrypt_key(encrypted: &[u8], version: Option) -> Result, String> { - match version.unwrap_or(0) { - 0 => Ok(encrypted.to_vec()), - 1 => AuthConfig::get().decrypt_user_key(encrypted), - v => Err(format!("Unknown encryption version: {}", v)), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionVersion { + Unencrypted, + AesGcm, +} + +impl EncryptionVersion { + pub fn from_db(version: Option) -> Result { + match version.unwrap_or(0) { + 0 => Ok(Self::Unencrypted), + 1 => Ok(Self::AesGcm), + v => Err(CryptoError::UnknownEncryptionVersion(v)), + } + } + + pub fn from_db_required(version: i32) -> Result { + Self::from_db(Some(version)) + } +} + +pub fn decrypt_key(encrypted: &[u8], version: Option) -> Result, CryptoError> { + match EncryptionVersion::from_db(version)? { + EncryptionVersion::Unencrypted => Ok(encrypted.to_vec()), + EncryptionVersion::AesGcm => AuthConfig::get().decrypt_user_key(encrypted), } } diff --git a/crates/tranquil-pds/src/delegation/scopes.rs b/crates/tranquil-pds/src/delegation/scopes.rs index 8029561..41236b0 100644 --- a/crates/tranquil-pds/src/delegation/scopes.rs +++ b/crates/tranquil-pds/src/delegation/scopes.rs @@ -163,4 +163,16 @@ mod tests { fn test_validate_scopes_invalid() { assert!(validate_delegation_scopes("invalid:scope").is_err()); } + + #[test] + fn test_scope_presets_parse() { + SCOPE_PRESETS.iter().for_each(|p| { + validate_delegation_scopes(p.scopes).unwrap_or_else(|e| { + panic!( + "preset '{}' has invalid scopes '{}': {}", + p.name, p.scopes, e + ) + }); + }); + } } diff --git a/crates/tranquil-pds/src/image/mod.rs b/crates/tranquil-pds/src/image/mod.rs index 7051886..2560065 100644 --- a/crates/tranquil-pds/src/image/mod.rs +++ b/crates/tranquil-pds/src/image/mod.rs @@ -197,12 +197,16 @@ impl ImageProcessor { max_size: u32, ) -> Result { let (orig_width, orig_height) = (img.width(), img.height()); + let safe_f64_to_u32 = + |v: f64| -> u32 { u32::try_from(v.round() as u64).unwrap_or(u32::MAX) }; let (new_width, new_height) = if orig_width > orig_height { - let ratio = max_size as f64 / orig_width as f64; - (max_size, (orig_height as f64 * ratio) as u32) + let ratio = f64::from(max_size) / f64::from(orig_width); + let scaled = safe_f64_to_u32((f64::from(orig_height) * ratio).max(1.0)); + (max_size, scaled.min(max_size)) } else { - let ratio = max_size as f64 / orig_height as f64; - ((orig_width as f64 * ratio) as u32, max_size) + let ratio = f64::from(max_size) / f64::from(orig_height); + let scaled = safe_f64_to_u32((f64::from(orig_width) * ratio).max(1.0)); + (scaled.min(max_size), max_size) }; let thumb = img.resize(new_width, new_height, FilterType::Lanczos3); self.encode_image(&thumb) diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 25c699d..810f53b 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod appview; pub mod auth; pub mod cache; +pub mod cache_keys; pub mod cid_types; pub mod circuit_breaker; pub mod comms; @@ -658,27 +659,91 @@ pub fn app(state: AppState) -> Router { .layer(DefaultBodyLimit::max(64 * 1024)), ) .layer(DefaultBodyLimit::max(util::get_max_blob_size())) + .layer(axum::middleware::map_response(rewrite_422_to_400)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer( CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers([ - "Authorization".parse().unwrap(), - "Content-Type".parse().unwrap(), - "Content-Encoding".parse().unwrap(), - "Accept-Encoding".parse().unwrap(), - "DPoP".parse().unwrap(), - "atproto-proxy".parse().unwrap(), - "atproto-accept-labelers".parse().unwrap(), - "x-bsky-topics".parse().unwrap(), + http::header::AUTHORIZATION, + http::header::CONTENT_TYPE, + http::header::CONTENT_ENCODING, + http::header::ACCEPT_ENCODING, + util::HEADER_DPOP, + util::HEADER_ATPROTO_PROXY, + util::HEADER_ATPROTO_ACCEPT_LABELERS, + util::HEADER_X_BSKY_TOPICS, ]) .expose_headers([ - "WWW-Authenticate".parse().unwrap(), - "DPoP-Nonce".parse().unwrap(), - "atproto-repo-rev".parse().unwrap(), - "atproto-content-labelers".parse().unwrap(), + http::header::WWW_AUTHENTICATE, + util::HEADER_DPOP_NONCE, + util::HEADER_ATPROTO_REPO_REV, + util::HEADER_ATPROTO_CONTENT_LABELERS, ]), ) .with_state(state) } + +async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response { + if response.status() != StatusCode::UNPROCESSABLE_ENTITY { + return response; + } + let (mut parts, body) = response.into_parts(); + let bytes = match axum::body::to_bytes(body, 64 * 1024).await { + Ok(b) => b, + Err(_) => { + parts.status = StatusCode::BAD_REQUEST; + parts.headers.remove(http::header::CONTENT_LENGTH); + let fallback = json!({"error": "InvalidRequest", "message": "Invalid request body"}); + return axum::response::Response::from_parts( + parts, + axum::body::Body::from(serde_json::to_vec(&fallback).unwrap_or_default()), + ); + } + }; + let raw = serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from)) + .unwrap_or_else(|| { + String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| "Invalid request body".into()) + }); + let message = humanize_json_error(&raw); + + parts.status = StatusCode::BAD_REQUEST; + parts.headers.remove(http::header::CONTENT_LENGTH); + let error_name = classify_deserialization_error(&raw); + let new_body = json!({ + "error": error_name, + "message": message + }); + axum::response::Response::from_parts( + parts, + axum::body::Body::from(serde_json::to_vec(&new_body).unwrap_or_default()), + ) +} + +fn humanize_json_error(raw: &str) -> String { + if raw.contains("missing field") { + raw.split("missing field `") + .nth(1) + .and_then(|s| s.split('`').next()) + .map(|field| format!("Missing required field: {}", field)) + .unwrap_or_else(|| raw.to_string()) + } else if raw.contains("invalid type") { + format!("Invalid field type: {}", raw) + } else if raw.contains("Invalid JSON") || raw.contains("syntax") { + "Invalid JSON syntax".to_string() + } else if raw.contains("Content-Type") || raw.contains("content type") { + "Content-Type must be application/json".to_string() + } else { + raw.to_string() + } +} + +fn classify_deserialization_error(raw: &str) -> &'static str { + match raw { + s if s.contains("invalid handle") => "InvalidHandle", + _ => "InvalidRequest", + } +} diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index c764ead..4369e7a 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -1,5 +1,5 @@ use crate::auth::{BareLoginIdentifier, NormalizedLoginIdentifier}; -use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; +use crate::comms::comms_repo::enqueue_2fa_code; use crate::oauth::{ AuthFlow, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent, scopes::expand_include_scopes, @@ -23,7 +23,7 @@ use axum::{ use chrono::Utc; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; -use tranquil_db_traits::ScopePreference; +use tranquil_db_traits::{ScopePreference, WebauthnChallengeType}; use tranquil_types::{AuthorizationCode, ClientId, DeviceId as DeviceIdType, RequestId}; use urlencoding::encode as url_encode; @@ -85,7 +85,7 @@ fn is_valid_scope(s: &str) -> bool { || s.starts_with("include:") } -fn extract_device_cookie(headers: &HeaderMap) -> Option { +fn extract_device_cookie(headers: &HeaderMap) -> Option { headers .get("cookie") .and_then(|v| v.to_str().ok()) @@ -94,6 +94,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option { cookie .strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) .and_then(|value| crate::config::AuthConfig::get().verify_device_cookie(value)) + .map(tranquil_types::DeviceId::new) }) }) } @@ -105,8 +106,8 @@ fn extract_user_agent(headers: &HeaderMap) -> Option { .map(|s| s.to_string()) } -fn make_device_cookie(device_id: &str) -> String { - let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id); +fn make_device_cookie(device_id: &tranquil_types::DeviceId) -> String { + let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id.as_str()); format!( "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", DEVICE_COOKIE_NAME, signed_value @@ -313,7 +314,7 @@ pub async fn authorize_get( && let Some(device_id) = extract_device_cookie(&headers) && let Ok(accounts) = state .oauth_repo - .get_device_accounts(&DeviceIdType::from(device_id.clone())) + .get_device_accounts(&device_id.clone()) .await && !accounts.is_empty() { @@ -419,8 +420,7 @@ pub async fn authorize_accounts( .into_response(); } }; - let device_id_typed = DeviceIdType::from(device_id.clone()); - let accounts = match state.oauth_repo.get_device_accounts(&device_id_typed).await { + let accounts = match state.oauth_repo.get_device_accounts(&device_id).await { Ok(accts) => accts, Err(_) => { return Json(AccountsResponse { @@ -693,7 +693,7 @@ pub async fn authorize_post( "Failed to enqueue 2FA notification" ); } - let channel_name = channel_display_name(user.preferred_comms_channel); + let channel_name = user.preferred_comms_channel.display_name(); if json_response { return Json(serde_json::json!({ "needs_2fa": true, @@ -712,38 +712,37 @@ pub async fn authorize_post( } } } - let mut device_id: Option = extract_device_cookie(&headers); + let mut device_id: Option = extract_device_cookie(&headers); let mut new_cookie: Option = None; if form.remember_device { let final_device_id = if let Some(existing_id) = &device_id { existing_id.clone() } else { let new_id = DeviceId::generate(); + let new_device_id_typed = DeviceIdType::new(new_id.0.clone()); let device_data = DeviceData { session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), ip_address: extract_client_ip(&headers, None), last_seen_at: Utc::now(), }; - let new_device_id_typed = DeviceIdType::from(new_id.0.clone()); if state .oauth_repo .create_device(&new_device_id_typed, &device_data) .await .is_ok() { - new_cookie = Some(make_device_cookie(&new_id.0)); - device_id = Some(new_id.0.clone()); + new_cookie = Some(make_device_cookie(&new_device_id_typed)); + device_id = Some(new_device_id_typed.clone()); } - new_id.0 + new_device_id_typed }; - let final_device_typed = DeviceIdType::from(final_device_id.clone()); let _ = state .oauth_repo - .upsert_account_device(&user.did, &final_device_typed) + .upsert_account_device(&user.did, &final_device_id) .await; } - let set_auth_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone())); + let set_auth_device_id = device_id.clone(); if state .oauth_repo .set_authorization_did(&form_request_id, &user.did, set_auth_device_id.as_ref()) @@ -796,7 +795,7 @@ pub async fn authorize_post( return redirect_see_other(&consent_url); } let code = Code::generate(); - let auth_post_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone())); + let auth_post_device_id = device_id.clone(); let auth_post_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -915,7 +914,7 @@ pub async fn authorize_select( ); } }; - let verify_device_id = DeviceIdType::from(device_id.clone()); + let verify_device_id = device_id.clone(); let account_valid = match state .oauth_repo .verify_account_on_device(&verify_device_id, &did) @@ -963,7 +962,7 @@ pub async fn authorize_select( ); } let has_totp = crate::api::server::has_totp_enabled(&state, &did).await; - let select_early_device_typed = DeviceIdType::from(device_id.clone()); + let select_early_device_typed = device_id.clone(); if has_totp { if state .oauth_repo @@ -1009,7 +1008,7 @@ pub async fn authorize_select( "Failed to enqueue 2FA notification" ); } - let channel_name = channel_display_name(user.preferred_comms_channel); + let channel_name = user.preferred_comms_channel.display_name(); return Json(serde_json::json!({ "needs_2fa": true, "channel": channel_name @@ -1025,7 +1024,7 @@ pub async fn authorize_select( } } } - let select_device_typed = DeviceIdType::from(device_id.clone()); + let select_device_typed = device_id.clone(); let _ = state .oauth_repo .upsert_account_device(&did, &select_device_typed) @@ -1714,7 +1713,7 @@ pub async fn consent_post( let consent_post_device_id = request_data .device_id .as_ref() - .map(|d| DeviceIdType::from(d.0.clone())); + .map(|d| DeviceIdType::new(d.0.clone())); let consent_post_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -1837,7 +1836,7 @@ pub async fn authorize_2fa_post( let _ = state.oauth_repo.delete_2fa_challenge(challenge.id).await; let code = Code::generate(); let device_id = extract_device_cookie(&headers); - let twofa_totp_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone())); + let twofa_totp_device_id = device_id.clone(); let twofa_totp_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -1945,7 +1944,7 @@ pub async fn authorize_2fa_post( return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); } let code = Code::generate(); - let twofa_final_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone())); + let twofa_final_device_id = device_id.clone(); let twofa_final_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -2273,7 +2272,11 @@ pub async fn passkey_start( if let Err(e) = state .user_repo - .save_webauthn_challenge(&user.did, "authentication", &state_json) + .save_webauthn_challenge( + &user.did, + WebauthnChallengeType::Authentication, + &state_json, + ) .await { tracing::error!(error = %e, "Failed to save authentication state"); @@ -2461,7 +2464,7 @@ pub async fn passkey_finish( let auth_state_json = match state .user_repo - .load_webauthn_challenge(passkey_owner_did, "authentication") + .load_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) .await { Ok(Some(s)) => s, @@ -2540,7 +2543,7 @@ pub async fn passkey_finish( if let Err(e) = state .user_repo - .delete_webauthn_challenge(passkey_owner_did, "authentication") + .delete_webauthn_challenge(passkey_owner_did, WebauthnChallengeType::Authentication) .await { tracing::warn!(error = %e, "Failed to delete authentication state"); @@ -2550,7 +2553,10 @@ pub async fn passkey_finish( let cred_id_bytes = auth_result.cred_id().as_slice(); match state .user_repo - .update_passkey_counter(cred_id_bytes, auth_result.counter() as i32) + .update_passkey_counter( + cred_id_bytes, + i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), + ) .await { Ok(false) => { @@ -2608,7 +2614,7 @@ pub async fn passkey_finish( { tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); } - let channel_name = channel_display_name(user.preferred_comms_channel); + let channel_name = user.preferred_comms_channel.display_name(); return Json(serde_json::json!({ "needs_2fa": true, "channel": channel_name @@ -2658,7 +2664,7 @@ pub async fn passkey_finish( } let code = Code::generate(); - let passkey_final_device_id = device_id.as_ref().map(|d| DeviceIdType::from(d.clone())); + let passkey_final_device_id = device_id.clone(); let passkey_final_code = AuthorizationCode::from(code.0.clone()); if state .oauth_repo @@ -2844,7 +2850,7 @@ pub async fn authorize_passkey_start( if let Err(e) = state .user_repo - .save_webauthn_challenge(&did, "authentication", &state_json) + .save_webauthn_challenge(&did, WebauthnChallengeType::Authentication, &state_json) .await { tracing::error!("Failed to save authentication state: {:?}", e); @@ -2951,7 +2957,7 @@ pub async fn authorize_passkey_finish( let auth_state_json = match state .user_repo - .load_webauthn_challenge(&did, "authentication") + .load_webauthn_challenge(&did, WebauthnChallengeType::Authentication) .await { Ok(Some(s)) => s, @@ -3025,12 +3031,15 @@ pub async fn authorize_passkey_finish( let _ = state .user_repo - .delete_webauthn_challenge(&did, "authentication") + .delete_webauthn_challenge(&did, WebauthnChallengeType::Authentication) .await; match state .user_repo - .update_passkey_counter(credential.id.as_ref(), auth_result.counter() as i32) + .update_passkey_counter( + credential.id.as_ref(), + i32::try_from(auth_result.counter()).unwrap_or(i32::MAX), + ) .await { Ok(false) => { @@ -3101,7 +3110,7 @@ pub async fn authorize_passkey_finish( { tracing::warn!(did = %did, error = %e, "Failed to enqueue 2FA notification"); } - let channel_name = channel_display_name(user.preferred_comms_channel); + let channel_name = user.preferred_comms_channel.display_name(); let redirect_url = format!( "/app/oauth/2fa?request_uri={}&channel={}", url_encode(&form.request_uri), @@ -3440,22 +3449,18 @@ pub async fn establish_session( let (device_id, new_cookie) = match existing_device { Some(id) => { - let device_typed = DeviceIdType::from(id.clone()); - let _ = state - .oauth_repo - .upsert_account_device(did, &device_typed) - .await; + let _ = state.oauth_repo.upsert_account_device(did, &id).await; (id, None) } None => { let new_id = DeviceId::generate(); + let device_typed = DeviceIdType::new(new_id.0.clone()); let device_data = DeviceData { session_id: SessionId::generate(), user_agent: extract_user_agent(&headers), ip_address: extract_client_ip(&headers, None), last_seen_at: Utc::now(), }; - let device_typed = DeviceIdType::from(new_id.0.clone()); if let Err(e) = state .oauth_repo @@ -3489,7 +3494,8 @@ pub async fn establish_session( .into_response(); } - (new_id.0.clone(), Some(make_device_cookie(&new_id.0))) + let cookie = make_device_cookie(&device_typed); + (device_typed, Some(cookie)) } }; diff --git a/crates/tranquil-pds/src/oauth/endpoints/par.rs b/crates/tranquil-pds/src/oauth/endpoints/par.rs index 3d97df5..91af079 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/par.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/par.rs @@ -131,7 +131,7 @@ pub async fn pushed_authorization_request( axum::http::StatusCode::CREATED, Json(ParResponse { request_uri: request_id.0, - expires_in: PAR_EXPIRY_SECONDS as u64, + expires_in: u64::try_from(PAR_EXPIRY_SECONDS).unwrap_or(600), }), )) } diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs index c5cf7eb..a3ece52 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs @@ -1,5 +1,7 @@ use super::helpers::{create_access_token_with_delegation, verify_pkce}; -use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest}; +use super::types::{ + RequestClientAuth, TokenGrant, TokenResponse, TokenType, ValidatedTokenRequest, +}; use crate::config::AuthConfig; use crate::delegation::intersect_scopes; use crate::oauth::{ @@ -12,12 +14,12 @@ use crate::oauth::{ use crate::state::AppState; use crate::util::pds_hostname; use axum::Json; -use axum::http::HeaderMap; +use axum::http::{HeaderMap, Method}; use chrono::{Duration, Utc}; use tranquil_db_traits::RefreshTokenLookup; use tranquil_types::{AuthorizationCode, Did, RefreshToken as RefreshTokenType}; -const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 300; +const ACCESS_TOKEN_EXPIRY_SECONDS: u64 = 300; const REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL: i64 = 60; const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14; @@ -29,7 +31,7 @@ pub async fn handle_authorization_code_grant( ) -> Result<(HeaderMap, Json), OAuthError> { tracing::info!( has_dpop = dpop_proof.is_some(), - client_id = ?request.client_auth.client_id, + client_id = ?request.client_auth.client_id(), "Authorization code grant requested" ); let (code, code_verifier, redirect_uri) = match request.grant { @@ -59,32 +61,33 @@ pub async fn handle_authorization_code_grant( .require_authorized() .map_err(|_| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; - if let Some(request_client_id) = &request.client_auth.client_id - && request_client_id != &authorized.client_id + if let Some(request_client_id) = request.client_auth.client_id() + && request_client_id != authorized.client_id { return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); } let did = authorized.did.to_string(); let client_metadata_cache = ClientMetadataCache::new(3600); let client_metadata = client_metadata_cache.get(&authorized.client_id).await?; - let client_auth = if let (Some(assertion), Some(assertion_type)) = ( - &request.client_auth.client_assertion, - &request.client_auth.client_assertion_type, - ) { - if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { - return Err(OAuthError::InvalidClient( - "Unsupported client_assertion_type".to_string(), - )); + let client_auth = match &request.client_auth { + RequestClientAuth::PrivateKeyJwt { + assertion, + assertion_type, + .. + } => { + if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { + return Err(OAuthError::InvalidClient( + "Unsupported client_assertion_type".to_string(), + )); + } + ClientAuth::PrivateKeyJwt { + client_assertion: assertion.clone(), + } } - ClientAuth::PrivateKeyJwt { - client_assertion: assertion.clone(), - } - } else if let Some(secret) = &request.client_auth.client_secret { - ClientAuth::SecretPost { - client_secret: secret.clone(), - } - } else { - ClientAuth::None + RequestClientAuth::SecretPost { client_secret, .. } => ClientAuth::SecretPost { + client_secret: client_secret.clone(), + }, + RequestClientAuth::None { .. } => ClientAuth::None, }; verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?; verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?; @@ -100,7 +103,7 @@ pub async fn handle_authorization_code_grant( let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); let pds_hostname = pds_hostname(); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); - let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; if !state .oauth_repo .check_and_record_dpop_jti(&result.jti) @@ -220,13 +223,20 @@ pub async fn handle_authorization_code_grant( let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); + let nonce = verifier.generate_nonce(); + let nonce_header = nonce.parse().map_err(|_| { + OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string()) + })?; + response_headers.insert("DPoP-Nonce", nonce_header); Ok(( response_headers, Json(TokenResponse { access_token, - token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + token_type: match dpop_jkt { + Some(_) => TokenType::DPoP, + None => TokenType::Bearer, + }, + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(refresh_token.0), scope: final_scope, sub: Some(did), @@ -283,13 +293,20 @@ pub async fn handle_refresh_token_grant( let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); + let nonce = verifier.generate_nonce(); + let nonce_header = nonce.parse().map_err(|_| { + OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string()) + })?; + response_headers.insert("DPoP-Nonce", nonce_header); return Ok(( response_headers, Json(TokenResponse { access_token, - token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + token_type: match dpop_jkt { + Some(_) => TokenType::DPoP, + None => TokenType::Bearer, + }, + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: token_data.current_refresh_token.map(|r| r.0), scope: token_data.scope, sub: Some(token_data.did.to_string()), @@ -333,7 +350,7 @@ pub async fn handle_refresh_token_grant( let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); let pds_hostname = pds_hostname(); let token_endpoint = format!("https://{}/oauth/token", pds_hostname); - let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + let result = verifier.verify_proof(proof, Method::POST.as_str(), &token_endpoint, None)?; if !state .oauth_repo .check_and_record_dpop_jti(&result.jti) @@ -387,13 +404,20 @@ pub async fn handle_refresh_token_grant( let mut response_headers = HeaderMap::new(); let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap()); + let nonce = verifier.generate_nonce(); + let nonce_header = nonce.parse().map_err(|_| { + OAuthError::ServerError("Failed to encode DPoP nonce as header value".to_string()) + })?; + response_headers.insert("DPoP-Nonce", nonce_header); Ok(( response_headers, Json(TokenResponse { access_token, - token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + token_type: match dpop_jkt { + Some(_) => TokenType::DPoP, + None => TokenType::Bearer, + }, + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token: Some(new_refresh_token.0), scope: token_data.scope, sub: Some(token_data.did.to_string()), diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs index f74e6e6..b0f02da 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/helpers.rs @@ -77,8 +77,14 @@ pub fn create_access_token_with_delegation( "alg": "HS256", "typ": "at+jwt" }); - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).map_err(|_| { + OAuthError::ServerError("token header serialization failed".to_string()) + })?); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).map_err(|_| { + OAuthError::ServerError("token payload serialization failed".to_string()) + })?); let signing_input = format!("{}.{}", header_b64, payload_b64); let config = AuthConfig::get(); type HmacSha256 = hmac::Hmac; diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs b/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs index de3db61..f0b3c1c 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/mod.rs @@ -15,7 +15,7 @@ pub use introspect::{ IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token, }; pub use types::{ - ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest, + GrantType, RequestClientAuth, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest, }; pub async fn token_endpoint( @@ -41,7 +41,7 @@ pub async fn token_endpoint( )); }; let dpop_proof = headers - .get("DPoP") + .get(crate::util::HEADER_DPOP) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let validated = request.validate()?; diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/types.rs b/crates/tranquil-pds/src/oauth/endpoints/token/types.rs index 893cf59..b7f1a2b 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/types.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/types.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; pub enum GrantType { AuthorizationCode, RefreshToken, - Unsupported(String), } impl GrantType { @@ -13,45 +12,36 @@ impl GrantType { match self { Self::AuthorizationCode => "authorization_code", Self::RefreshToken => "refresh_token", - Self::Unsupported(s) => s, } } } +#[derive(Debug, Clone)] +pub struct UnsupportedGrantType(pub String); + +impl std::fmt::Display for UnsupportedGrantType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unsupported grant type: {}", self.0) + } +} + +impl std::error::Error for UnsupportedGrantType {} + impl std::str::FromStr for GrantType { - type Err = std::convert::Infallible; + type Err = UnsupportedGrantType; fn from_str(s: &str) -> Result { - Ok(match s { - "authorization_code" => Self::AuthorizationCode, - "refresh_token" => Self::RefreshToken, - other => Self::Unsupported(other.to_string()), - }) - } -} - -impl<'de> Deserialize<'de> for GrantType { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Ok(s.parse().unwrap()) - } -} - -impl Serialize for GrantType { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.as_str()) + match s { + "authorization_code" => Ok(Self::AuthorizationCode), + "refresh_token" => Ok(Self::RefreshToken), + other => Err(UnsupportedGrantType(other.to_string())), + } } } #[derive(Debug, Deserialize)] pub struct TokenRequest { - pub grant_type: GrantType, + pub grant_type: String, #[serde(default)] pub code: Option, #[serde(default)] @@ -82,23 +72,45 @@ pub enum TokenGrant { }, } -#[derive(Debug, Clone, Default)] -pub struct ClientAuthParams { - pub client_id: Option, - pub client_secret: Option, - pub client_assertion: Option, - pub client_assertion_type: Option, +#[derive(Debug, Clone)] +pub enum RequestClientAuth { + None { + client_id: Option, + }, + SecretPost { + client_id: Option, + client_secret: String, + }, + PrivateKeyJwt { + client_id: Option, + assertion: String, + assertion_type: String, + }, +} + +impl RequestClientAuth { + pub fn client_id(&self) -> Option<&str> { + match self { + Self::None { client_id } + | Self::SecretPost { client_id, .. } + | Self::PrivateKeyJwt { client_id, .. } => client_id.as_deref(), + } + } } #[derive(Debug, Clone)] pub struct ValidatedTokenRequest { pub grant: TokenGrant, - pub client_auth: ClientAuthParams, + pub client_auth: RequestClientAuth, } impl TokenRequest { pub fn validate(self) -> Result { - let grant = match self.grant_type { + let grant_type: GrantType = self + .grant_type + .parse() + .map_err(|e: UnsupportedGrantType| OAuthError::UnsupportedGrantType(e.0))?; + let grant = match grant_type { GrantType::AuthorizationCode => { let code = self.code.ok_or_else(|| { OAuthError::InvalidRequest( @@ -124,26 +136,41 @@ impl TokenRequest { })?; TokenGrant::RefreshToken { refresh_token } } - GrantType::Unsupported(grant_type) => { - return Err(OAuthError::UnsupportedGrantType(grant_type)); - } }; - let client_auth = ClientAuthParams { - client_id: self.client_id, - client_secret: self.client_secret, - client_assertion: self.client_assertion, - client_assertion_type: self.client_assertion_type, + let client_auth = match (self.client_assertion, self.client_assertion_type) { + (Some(assertion), Some(assertion_type)) => RequestClientAuth::PrivateKeyJwt { + client_id: self.client_id, + assertion, + assertion_type, + }, + _ => match self.client_secret { + Some(secret) => RequestClientAuth::SecretPost { + client_id: self.client_id, + client_secret: secret, + }, + None => RequestClientAuth::None { + client_id: self.client_id, + }, + }, }; Ok(ValidatedTokenRequest { grant, client_auth }) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TokenType { + Bearer, + #[serde(rename = "DPoP")] + DPoP, +} + #[derive(Debug, Serialize)] pub struct TokenResponse { pub access_token: String, - pub token_type: String, + pub token_type: TokenType, pub expires_in: u64, #[serde(skip_serializing_if = "Option::is_none")] pub refresh_token: Option, diff --git a/crates/tranquil-pds/src/oauth/verify.rs b/crates/tranquil-pds/src/oauth/verify.rs index 6681367..6533785 100644 --- a/crates/tranquil-pds/src/oauth/verify.rs +++ b/crates/tranquil-pds/src/oauth/verify.rs @@ -12,6 +12,7 @@ use subtle::ConstantTimeEq; use tranquil_db_traits::{OAuthRepository, UserRepository}; use tranquil_types::{ClientId, TokenId}; +use crate::auth::AuthSource; use crate::types::Did; use super::scopes::ScopePermissions; @@ -217,7 +218,7 @@ pub struct OAuthUser { pub did: Did, pub client_id: Option, pub scope: Option, - pub is_oauth: bool, + pub auth_source: AuthSource, pub permissions: ScopePermissions, } @@ -240,14 +241,22 @@ impl IntoResponse for OAuthAuthError { ) .into_response(); if let Some(nonce) = self.dpop_nonce { - response - .headers_mut() - .insert("DPoP-Nonce", nonce.parse().unwrap()); + match nonce.parse() { + Ok(val) => { + response.headers_mut().insert("DPoP-Nonce", val); + } + Err(e) => tracing::warn!(error = %e, "DPoP-Nonce header value failed to encode"), + } } if let Some(www_auth) = self.www_authenticate { - response - .headers_mut() - .insert("WWW-Authenticate", www_auth.parse().unwrap()); + match www_auth.parse() { + Ok(val) => { + response.headers_mut().insert("WWW-Authenticate", val); + } + Err(e) => { + tracing::warn!(error = %e, "WWW-Authenticate header value failed to encode") + } + } } response } @@ -289,13 +298,16 @@ impl FromRequestParts for OAuthUser { www_authenticate: None, }); }; - let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok()); + let dpop_proof = parts + .headers + .get(crate::util::HEADER_DPOP) + .and_then(|v| v.to_str().ok()); if let Ok(result) = try_legacy_auth(state.user_repo.as_ref(), token).await { return Ok(OAuthUser { did: result.did, client_id: None, scope: None, - is_oauth: false, + auth_source: AuthSource::Session, permissions: ScopePermissions::default(), }); } @@ -316,7 +328,7 @@ impl FromRequestParts for OAuthUser { did: result.did, client_id: Some(result.client_id), scope: result.scope, - is_oauth: true, + auth_source: AuthSource::OAuth, permissions, }) } diff --git a/crates/tranquil-pds/src/plc/mod.rs b/crates/tranquil-pds/src/plc/mod.rs index 94689c4..8d6b6a4 100644 --- a/crates/tranquil-pds/src/plc/mod.rs +++ b/crates/tranquil-pds/src/plc/mod.rs @@ -31,10 +31,41 @@ pub enum PlcError { CircuitBreakerOpen, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlcOpType { + #[serde(rename = "plc_operation")] + Operation, + #[serde(rename = "plc_tombstone")] + Tombstone, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServiceType { + #[serde(rename = "AtprotoPersonalDataServer")] + Pds, + #[serde(rename = "AtprotoAppView")] + AppView, +} + +impl ServiceType { + pub fn as_str(self) -> &'static str { + match self { + Self::Pds => "AtprotoPersonalDataServer", + Self::AppView => "AtprotoAppView", + } + } + + pub fn is_pds(self) -> bool { + matches!(self, Self::Pds) + } +} + +pub const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01]; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcOperation { #[serde(rename = "type")] - pub op_type: String, + pub op_type: PlcOpType, #[serde(rename = "rotationKeys")] pub rotation_keys: Vec, #[serde(rename = "verificationMethods")] @@ -50,14 +81,14 @@ pub struct PlcOperation { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcService { #[serde(rename = "type")] - pub service_type: String, + pub service_type: ServiceType, pub endpoint: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlcTombstone { #[serde(rename = "type")] - pub op_type: String, + pub op_type: PlcOpType, pub prev: String, #[serde(skip_serializing_if = "Option::is_none")] pub sig: Option, @@ -74,7 +105,7 @@ impl PlcOpOrTombstone { pub fn is_tombstone(&self) -> bool { match self { PlcOpOrTombstone::Tombstone(_) => true, - PlcOpOrTombstone::Operation(op) => op.op_type == "plc_tombstone", + PlcOpOrTombstone::Operation(op) => op.op_type == PlcOpType::Tombstone, } } } @@ -124,7 +155,7 @@ impl PlcClient { } pub async fn get_document(&self, did: &str) -> Result { - let cache_key = format!("plc:doc:{}", did); + let cache_key = crate::cache_keys::plc_doc_key(did); if let Some(ref cache) = self.cache && let Some(cached) = cache.get(&cache_key).await && let Ok(value) = serde_json::from_str(&cached) @@ -163,7 +194,7 @@ impl PlcClient { } pub async fn get_document_data(&self, did: &str) -> Result { - let cache_key = format!("plc:data:{}", did); + let cache_key = crate::cache_keys::plc_data_key(did); if let Some(ref cache) = self.cache && let Some(cached) = cache.get(&cache_key).await && let Ok(value) = serde_json::from_str(&cached) @@ -313,7 +344,7 @@ pub fn create_update_op( } }; let new_op = PlcOperation { - op_type: "plc_operation".to_string(), + op_type: PlcOpType::Operation, rotation_keys: rotation_keys.unwrap_or(base_rotation_keys), verification_methods: verification_methods.unwrap_or(base_verification_methods), also_known_as: also_known_as.unwrap_or(base_also_known_as), @@ -328,7 +359,7 @@ pub fn signing_key_to_did_key(signing_key: &SigningKey) -> String { let verifying_key = signing_key.verifying_key(); let point = verifying_key.to_encoded_point(true); let compressed_bytes = point.as_bytes(); - let mut prefixed = vec![0xe7, 0x01]; + let mut prefixed = Vec::from(SECP256K1_MULTICODEC_PREFIX); prefixed.extend_from_slice(compressed_bytes); let encoded = multibase::encode(multibase::Base::Base58Btc, &prefixed); format!("did:key:{}", encoded) @@ -352,12 +383,12 @@ pub fn create_genesis_operation( services.insert( "atproto_pds".to_string(), PlcService { - service_type: "AtprotoPersonalDataServer".to_string(), + service_type: ServiceType::Pds, endpoint: pds_endpoint.to_string(), }, ); let genesis_op = PlcOperation { - op_type: "plc_operation".to_string(), + op_type: PlcOpType::Operation, rotation_keys: vec![rotation_key.to_string()], verification_methods, also_known_as: vec![format!("at://{}", handle)], @@ -386,42 +417,39 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result { Ok(format!("did:plc:{}", truncated)) } -pub fn validate_plc_operation(op: &Value) -> Result<(), PlcError> { +pub fn validate_plc_operation(op: &Value) -> Result { let obj = op .as_object() .ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?; - let op_type = obj + let op_type_str = obj .get("type") - .and_then(|v| v.as_str()) .ok_or_else(|| PlcError::InvalidResponse("Missing type field".to_string()))?; - if op_type != "plc_operation" && op_type != "plc_tombstone" { - return Err(PlcError::InvalidResponse(format!( + let op_type: PlcOpType = serde_json::from_value(op_type_str.clone()).map_err(|_| { + PlcError::InvalidResponse(format!( "Invalid type: {}", - op_type - ))); - } - if op_type == "plc_operation" { - if obj.get("rotationKeys").is_none() { - return Err(PlcError::InvalidResponse( - "Missing rotationKeys".to_string(), - )); - } - if obj.get("verificationMethods").is_none() { - return Err(PlcError::InvalidResponse( - "Missing verificationMethods".to_string(), - )); - } - if obj.get("alsoKnownAs").is_none() { - return Err(PlcError::InvalidResponse("Missing alsoKnownAs".to_string())); - } - if obj.get("services").is_none() { - return Err(PlcError::InvalidResponse("Missing services".to_string())); + op_type_str.as_str().unwrap_or("") + )) + })?; + match op_type { + PlcOpType::Operation => { + let required_fields = [ + "rotationKeys", + "verificationMethods", + "alsoKnownAs", + "services", + ]; + required_fields.iter().try_for_each(|field| { + obj.get(*field) + .map(|_| ()) + .ok_or_else(|| PlcError::InvalidResponse(format!("Missing {}", field))) + })?; } + PlcOpType::Tombstone => {} } if obj.get("sig").is_none() { return Err(PlcError::InvalidResponse("Missing sig".to_string())); } - Ok(()) + Ok(op_type) } pub struct PlcValidationContext { @@ -435,14 +463,13 @@ pub fn validate_plc_operation_for_submission( op: &Value, ctx: &PlcValidationContext, ) -> Result<(), PlcError> { - validate_plc_operation(op)?; + let op_type = validate_plc_operation(op)?; + if op_type != PlcOpType::Operation { + return Ok(()); + } let obj = op .as_object() .ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?; - let op_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); - if op_type != "plc_operation" { - return Ok(()); - } let rotation_keys = obj .get("rotationKeys") .and_then(|v| v.as_array()) @@ -489,7 +516,7 @@ pub fn validate_plc_operation_for_submission( .get("type") .and_then(|v| v.as_str()) .unwrap_or(""); - if service_type != "AtprotoPersonalDataServer" { + if service_type != ServiceType::Pds.as_str() { return Err(PlcError::InvalidResponse( "Incorrect type on atproto_pds service".to_string(), )); @@ -551,18 +578,13 @@ fn verify_signature_with_did_key( "Invalid did:key data".to_string(), )); } - let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 { - (0xe701u16, &decoded[2..]) + let key_bytes = if decoded.starts_with(&SECP256K1_MULTICODEC_PREFIX) { + &decoded[SECP256K1_MULTICODEC_PREFIX.len()..] } else { return Err(PlcError::InvalidResponse( - "Unsupported key type in did:key".to_string(), + "Unsupported key type in did:key (expected secp256k1)".to_string(), )); }; - if codec != 0xe701 { - return Err(PlcError::InvalidResponse( - "Only secp256k1 keys are supported".to_string(), - )); - } let verifying_key = VerifyingKey::from_sec1_bytes(key_bytes) .map_err(|e| PlcError::InvalidResponse(format!("Invalid public key: {}", e)))?; Ok(verifying_key.verify(message, signature).is_ok()) diff --git a/crates/tranquil-pds/src/rate_limit/mod.rs b/crates/tranquil-pds/src/rate_limit/mod.rs index c72f61b..a5d6f1b 100644 --- a/crates/tranquil-pds/src/rate_limit/mod.rs +++ b/crates/tranquil-pds/src/rate_limit/mod.rs @@ -46,121 +46,121 @@ impl RateLimiters { pub fn new() -> Self { Self { login: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(300).unwrap(), + const { NonZeroU32::new(300).unwrap() }, ))), oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), password_reset: Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(5).unwrap(), + const { NonZeroU32::new(5).unwrap() }, ))), account_creation: Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), refresh_session: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(60).unwrap(), + const { NonZeroU32::new(60).unwrap() }, ))), reset_password: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), oauth_par: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(30).unwrap(), + const { NonZeroU32::new(30).unwrap() }, ))), oauth_introspect: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(30).unwrap(), + const { NonZeroU32::new(30).unwrap() }, ))), app_password: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(5).unwrap(), + const { NonZeroU32::new(5).unwrap() }, ))), totp_verify: Arc::new(RateLimiter::keyed( Quota::with_period(std::time::Duration::from_secs(60)) .unwrap() - .allow_burst(NonZeroU32::new(5).unwrap()), + .allow_burst(const { NonZeroU32::new(5).unwrap() }), )), handle_update: Arc::new(RateLimiter::keyed( Quota::with_period(std::time::Duration::from_secs(30)) .unwrap() - .allow_burst(NonZeroU32::new(10).unwrap()), + .allow_burst(const { NonZeroU32::new(10).unwrap() }), )), handle_update_daily: Arc::new(RateLimiter::keyed( Quota::with_period(std::time::Duration::from_secs(1728)) .unwrap() - .allow_burst(NonZeroU32::new(50).unwrap()), + .allow_burst(const { NonZeroU32::new(50).unwrap() }), )), verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(60).unwrap(), + const { NonZeroU32::new(60).unwrap() }, ))), sso_initiate: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), sso_callback: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(30).unwrap(), + const { NonZeroU32::new(30).unwrap() }, ))), sso_unlink: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), oauth_register_complete: Arc::new(RateLimiter::keyed( Quota::with_period(std::time::Duration::from_secs(60)) .unwrap() - .allow_burst(NonZeroU32::new(5).unwrap()), + .allow_burst(const { NonZeroU32::new(5).unwrap() }), )), handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(10).unwrap(), + const { NonZeroU32::new(10).unwrap() }, ))), } } pub fn with_login_limit(mut self, per_minute: u32) -> Self { self.login = Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }), ))); self } pub fn with_oauth_token_limit(mut self, per_minute: u32) -> Self { self.oauth_token = Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()), + NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(30).unwrap() }), ))); self } pub fn with_oauth_authorize_limit(mut self, per_minute: u32) -> Self { self.oauth_authorize = Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }), ))); self } pub fn with_password_reset_limit(mut self, per_hour: u32) -> Self { self.password_reset = Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), + NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(5).unwrap() }), ))); self } pub fn with_account_creation_limit(mut self, per_hour: u32) -> Self { self.account_creation = Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()), + NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(10).unwrap() }), ))); self } pub fn with_email_update_limit(mut self, per_hour: u32) -> Self { self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour( - NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()), + NonZeroU32::new(per_hour).unwrap_or(const { NonZeroU32::new(5).unwrap() }), ))); self } pub fn with_sso_initiate_limit(mut self, per_minute: u32) -> Self { self.sso_initiate = Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()), + NonZeroU32::new(per_minute).unwrap_or(const { NonZeroU32::new(10).unwrap() }), ))); self } @@ -178,7 +178,7 @@ mod tests { #[test] fn test_rate_limiter_exhaustion() { - let limiter = RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(2).unwrap())); + let limiter = RateLimiter::keyed(Quota::per_minute(const { NonZeroU32::new(2).unwrap() })); let key = "test_ip".to_string(); assert!(limiter.check_key(&key).is_ok()); @@ -188,7 +188,7 @@ mod tests { #[test] fn test_different_keys_have_separate_limits() { - let limiter = RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(1).unwrap())); + let limiter = RateLimiter::keyed(Quota::per_minute(const { NonZeroU32::new(1).unwrap() })); assert!(limiter.check_key(&"ip1".to_string()).is_ok()); assert!(limiter.check_key(&"ip1".to_string()).is_err()); diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs index 46cbbe9..b292c11 100644 --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::commit::Commit; @@ -18,24 +19,51 @@ use crate::repo::PostgresBlockStore; use crate::storage::{BackupStorage, BlobStorage, backup_interval_secs, backup_retention_count}; use crate::sync::car::encode_car_header; +#[derive(Debug)] +enum GenesisBackfillError { + MissingCommitCid, + InvalidCid, + BlockFetchFailed, + BlockNotFound, + CommitParseFailed, + UpdateFailed, +} + +impl std::fmt::Display for GenesisBackfillError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingCommitCid => f.write_str("missing commit_cid"), + Self::InvalidCid => f.write_str("invalid CID"), + Self::BlockFetchFailed => f.write_str("failed to fetch block"), + Self::BlockNotFound => f.write_str("block not found"), + Self::CommitParseFailed => f.write_str("failed to parse commit"), + Self::UpdateFailed => f.write_str("failed to update"), + } + } +} + async fn process_genesis_commit( repo_repo: &dyn RepoRepository, block_store: &PostgresBlockStore, row: BrokenGenesisCommit, -) -> Result<(Did, SequenceNumber), (SequenceNumber, &'static str)> { - let commit_cid_str = row.commit_cid.ok_or((row.seq, "missing commit_cid"))?; - let commit_cid = Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, "invalid CID"))?; +) -> Result<(Did, SequenceNumber), (SequenceNumber, GenesisBackfillError)> { + let commit_cid_str = row + .commit_cid + .ok_or((row.seq, GenesisBackfillError::MissingCommitCid))?; + let commit_cid = + Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, GenesisBackfillError::InvalidCid))?; let block = block_store .get(&commit_cid) .await - .map_err(|_| (row.seq, "failed to fetch block"))? - .ok_or((row.seq, "block not found"))?; - let commit = Commit::from_cbor(&block).map_err(|_| (row.seq, "failed to parse commit"))?; + .map_err(|_| (row.seq, GenesisBackfillError::BlockFetchFailed))? + .ok_or((row.seq, GenesisBackfillError::BlockNotFound))?; + let commit = Commit::from_cbor(&block) + .map_err(|_| (row.seq, GenesisBackfillError::CommitParseFailed))?; let blocks_cids = vec![commit.data.to_string(), commit_cid.to_string()]; repo_repo .update_seq_blocks_cids(row.seq, &blocks_cids) .await - .map_err(|_| (row.seq, "failed to update"))?; + .map_err(|_| (row.seq, GenesisBackfillError::UpdateFailed))?; Ok((row.did, row.seq)) } @@ -79,7 +107,7 @@ pub async fn backfill_genesis_commit_blocks( Err((seq, reason)) => { warn!( seq = seq.as_i64(), - reason = reason, + reason = %reason, "Failed to process genesis commit" ); (s, f + 1) @@ -99,7 +127,17 @@ async fn process_repo_rev( repo_root_cid: String, ) -> Result { let cid = Cid::from_str(&repo_root_cid).map_err(|_| user_id)?; - let block = block_store.get(&cid).await.ok().flatten().ok_or(user_id)?; + let block = match block_store.get(&cid).await { + Ok(Some(b)) => b, + Ok(None) => { + tracing::warn!(user_id = %user_id, cid = %cid, "block not found for repo rev backfill"); + return Err(user_id); + } + Err(e) => { + tracing::warn!(user_id = %user_id, cid = %cid, error = %e, "block store error during repo rev backfill"); + return Err(user_id); + } + }; let commit = Commit::from_cbor(&block).map_err(|_| user_id)?; let rev = commit.rev().to_string(); repo_repo @@ -235,7 +273,7 @@ pub async fn backfill_user_blocks( pub async fn collect_current_repo_blocks( block_store: &PostgresBlockStore, head_cid: &Cid, -) -> Result>, String> { +) -> anyhow::Result>> { let mut block_cids: Vec> = Vec::new(); let mut to_visit = vec![*head_cid]; let mut visited = std::collections::HashSet::new(); @@ -250,7 +288,7 @@ pub async fn collect_current_repo_blocks( let block = match block_store.get(&cid).await { Ok(Some(b)) => b, Ok(None) => continue, - Err(e) => return Err(format!("Failed to get block {}: {:?}", cid, e)), + Err(e) => anyhow::bail!("Failed to get block {}: {:?}", cid, e), }; if let Ok(commit) = Commit::from_cbor(&block) { @@ -308,13 +346,19 @@ async fn process_record_blobs( Some( blob_refs .into_iter() - .map(|blob_ref| { + .filter_map(|blob_ref| { let record_uri = AtUri::from_parts( did.as_str(), record.collection.as_str(), record.rkey.as_str(), ); - (record_uri, unsafe { CidLink::new_unchecked(blob_ref.cid) }) + match CidLink::new(&blob_ref.cid) { + Ok(cid_link) => Some((record_uri, cid_link)), + Err(_) => { + tracing::warn!(cid = %blob_ref.cid, "skipping unparseable blob CID in record blob backfill"); + None + } + } }) .collect::>(), ) @@ -462,11 +506,11 @@ async fn process_scheduled_deletions( user_repo: &dyn UserRepository, blob_repo: &dyn BlobRepository, blob_store: &dyn BlobStorage, -) -> Result<(), String> { +) -> anyhow::Result<()> { let accounts_to_delete = user_repo .get_accounts_scheduled_for_deletion(100) .await - .map_err(|e| format!("DB error fetching accounts to delete: {:?}", e))?; + .context("DB error fetching accounts to delete")?; if accounts_to_delete.is_empty() { debug!("No accounts scheduled for deletion"); @@ -501,11 +545,11 @@ async fn delete_account_data( blob_store: &dyn BlobStorage, user_id: uuid::Uuid, did: &Did, -) -> Result<(), String> { +) -> anyhow::Result<()> { let blob_storage_keys = blob_repo .get_blob_storage_keys_by_user(user_id) .await - .map_err(|e| format!("DB error fetching blob keys: {:?}", e))?; + .context("DB error fetching blob keys")?; futures::future::join_all(blob_storage_keys.iter().map(|storage_key| async move { (storage_key, blob_store.delete(storage_key).await) @@ -520,7 +564,7 @@ async fn delete_account_data( let _account_seq = user_repo .delete_account_with_firehose(user_id, did) .await - .map_err(|e| format!("Failed to delete account: {:?}", e))?; + .context("Failed to delete account")?; info!( did = %did, @@ -570,7 +614,7 @@ pub async fn start_backup_tasks( } struct BackupResult { - did: String, + did: Did, repo_rev: String, size_bytes: i64, block_count: i32, @@ -579,8 +623,8 @@ struct BackupResult { enum BackupOutcome { Success(BackupResult), - Skipped(String, &'static str), - Failed(String, String), + Skipped(Did, &'static str), + Failed(Did, String), } #[allow(clippy::too_many_arguments)] @@ -590,7 +634,7 @@ async fn process_single_backup( block_store: &PostgresBlockStore, backup_storage: &dyn BackupStorage, user_id: uuid::Uuid, - did: String, + did: Did, repo_root_cid: String, repo_rev: Option, ) -> BackupOutcome { @@ -610,9 +654,12 @@ async fn process_single_backup( }; let block_count = count_car_blocks(&car_bytes); - let size_bytes = car_bytes.len() as i64; + let size_bytes = i64::try_from(car_bytes.len()).unwrap_or(i64::MAX); - let storage_key = match backup_storage.put_backup(&did, &repo_rev, &car_bytes).await { + let storage_key = match backup_storage + .put_backup(did.as_str(), &repo_rev, &car_bytes) + .await + { Ok(key) => key, Err(e) => return BackupOutcome::Failed(did, format!("S3 upload: {}", e)), }; @@ -653,14 +700,14 @@ async fn process_scheduled_backups( backup_repo: &dyn BackupRepository, block_store: &PostgresBlockStore, backup_storage: &dyn BackupStorage, -) -> Result<(), String> { - let interval_secs = backup_interval_secs() as i64; +) -> anyhow::Result<()> { + let interval_secs = i64::try_from(backup_interval_secs()).unwrap_or(i64::MAX); let retention = backup_retention_count(); let users_needing_backup = backup_repo .get_users_needing_backup(interval_secs, 50) .await - .map_err(|e| format!("DB error fetching users for backup: {:?}", e))?; + .context("DB error fetching users for backup")?; if users_needing_backup.is_empty() { debug!("No accounts need backup"); @@ -679,7 +726,7 @@ async fn process_scheduled_backups( block_store, backup_storage, user.id, - user.did.to_string(), + user.did, user.repo_root_cid.to_string(), user.repo_rev, ) @@ -719,22 +766,27 @@ async fn process_scheduled_backups( pub async fn generate_repo_car( block_store: &PostgresBlockStore, head_cid: &Cid, -) -> Result, String> { +) -> anyhow::Result> { use jacquard_repo::storage::BlockStore; let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?; let block_cids: Vec = block_cids_bytes .iter() - .filter_map(|b| Cid::try_from(b.as_slice()).ok()) + .filter_map(|b| match Cid::try_from(b.as_slice()) { + Ok(cid) => Some(cid), + Err(e) => { + tracing::warn!(error = %e, "skipping unparseable CID in backup generation"); + None + } + }) .collect(); - let car_bytes = - encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?; + let car_bytes = encode_car_header(head_cid).context("Failed to encode CAR header")?; let blocks = block_store .get_many(&block_cids) .await - .map_err(|e| format!("Failed to fetch blocks: {:?}", e))?; + .context("Failed to fetch blocks")?; let car_bytes = block_cids .iter() @@ -753,7 +805,7 @@ fn encode_car_block(cid: &Cid, block: &[u8]) -> Vec { let cid_bytes = cid.to_bytes(); let total_len = cid_bytes.len() + block.len(); let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64) + crate::sync::car::write_varint(&mut writer, u64::try_from(total_len).expect("len fits u64")) .expect("Writing to Vec should never fail"); writer .write_all(&cid_bytes) @@ -769,18 +821,17 @@ pub async fn generate_repo_car_from_user_blocks( block_store: &PostgresBlockStore, user_id: uuid::Uuid, _head_cid: &Cid, -) -> Result, String> { +) -> anyhow::Result> { use std::str::FromStr; let repo_root_cid_str: String = repo_repo .get_repo_root_cid_by_user_id(user_id) .await - .map_err(|e| format!("Failed to fetch repo: {:?}", e))? - .ok_or_else(|| "Repository not found".to_string())? + .context("Failed to fetch repo")? + .ok_or_else(|| anyhow::anyhow!("Repository not found"))? .to_string(); - let actual_head_cid = - Cid::from_str(&repo_root_cid_str).map_err(|e| format!("Invalid repo_root_cid: {}", e))?; + let actual_head_cid = Cid::from_str(&repo_root_cid_str).context("Invalid repo_root_cid")?; generate_repo_car(block_store, &actual_head_cid).await } @@ -790,24 +841,42 @@ pub async fn generate_full_backup( block_store: &PostgresBlockStore, user_id: uuid::Uuid, head_cid: &Cid, -) -> Result, String> { +) -> anyhow::Result> { generate_repo_car_from_user_blocks(repo_repo, block_store, user_id, head_cid).await } pub fn count_car_blocks(car_bytes: &[u8]) -> i32 { - let mut count = 0; - let mut pos = 0; + let mut count: i32 = 0; + let mut pos: usize = 0; if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) { - pos += header_varint_len + header_len as usize; + let Some(header_size) = usize::try_from(header_len).ok() else { + return 0; + }; + let Some(next_pos) = header_varint_len + .checked_add(header_size) + .and_then(|skip| pos.checked_add(skip)) + else { + return 0; + }; + pos = next_pos; } else { return 0; } while pos < car_bytes.len() { if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) { - pos += varint_len + block_len as usize; - count += 1; + let Some(block_size) = usize::try_from(block_len).ok() else { + break; + }; + let Some(next_pos) = varint_len + .checked_add(block_size) + .and_then(|skip| pos.checked_add(skip)) + else { + break; + }; + pos = next_pos; + count = count.saturating_add(1); } else { break; } @@ -839,21 +908,18 @@ async fn cleanup_old_backups( backup_storage: &dyn BackupStorage, user_id: uuid::Uuid, retention_count: u32, -) -> Result<(), String> { +) -> anyhow::Result<()> { let old_backups = backup_repo - .get_old_backups(user_id, retention_count as i64) + .get_old_backups(user_id, i64::from(retention_count)) .await - .map_err(|e| format!("DB error fetching old backups: {:?}", e))?; + .context("DB error fetching old backups")?; let results = futures::future::join_all(old_backups.into_iter().map(|backup| async move { match backup_storage.delete_backup(&backup.storage_key).await { - Ok(()) => match backup_repo.delete_backup(backup.id).await { - Ok(()) => Ok(()), - Err(e) => Err(format!( - "DB delete failed for {}: {:?}", - backup.storage_key, e - )), - }, + Ok(()) => backup_repo + .delete_backup(backup.id) + .await + .with_context(|| format!("DB delete failed for {}", backup.storage_key)), Err(e) => { warn!( storage_key = %backup.storage_key, diff --git a/crates/tranquil-pds/src/sso/config.rs b/crates/tranquil-pds/src/sso/config.rs index 5c313e6..3924ccb 100644 --- a/crates/tranquil-pds/src/sso/config.rs +++ b/crates/tranquil-pds/src/sso/config.rs @@ -80,9 +80,7 @@ impl SsoConfig { } fn load_provider(name: &str, needs_issuer: bool) -> Option { - let enabled = std::env::var(format!("SSO_{}_ENABLED", name)) - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let enabled = crate::util::parse_env_bool(&format!("SSO_{}_ENABLED", name)); if !enabled { return None; @@ -121,9 +119,7 @@ impl SsoConfig { } fn load_apple_provider() -> Option { - let enabled = std::env::var("SSO_APPLE_ENABLED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let enabled = crate::util::parse_env_bool("SSO_APPLE_ENABLED"); if !enabled { return None; @@ -178,35 +174,25 @@ impl SsoConfig { self.apple.as_ref() } + fn provider_configs(&self) -> [(SsoProviderType, bool); 6] { + [ + (SsoProviderType::Github, self.github.is_some()), + (SsoProviderType::Discord, self.discord.is_some()), + (SsoProviderType::Google, self.google.is_some()), + (SsoProviderType::Gitlab, self.gitlab.is_some()), + (SsoProviderType::Oidc, self.oidc.is_some()), + (SsoProviderType::Apple, self.apple.is_some()), + ] + } + pub fn enabled_providers(&self) -> Vec { - let mut providers = Vec::new(); - if self.github.is_some() { - providers.push(SsoProviderType::Github); - } - if self.discord.is_some() { - providers.push(SsoProviderType::Discord); - } - if self.google.is_some() { - providers.push(SsoProviderType::Google); - } - if self.gitlab.is_some() { - providers.push(SsoProviderType::Gitlab); - } - if self.oidc.is_some() { - providers.push(SsoProviderType::Oidc); - } - if self.apple.is_some() { - providers.push(SsoProviderType::Apple); - } - providers + self.provider_configs() + .into_iter() + .filter_map(|(p, enabled)| enabled.then_some(p)) + .collect() } pub fn is_any_enabled(&self) -> bool { - self.github.is_some() - || self.discord.is_some() - || self.google.is_some() - || self.gitlab.is_some() - || self.oidc.is_some() - || self.apple.is_some() + self.provider_configs().into_iter().any(|(_, e)| e) } } diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index ae262fd..ad5c881 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -36,7 +36,7 @@ fn generate_nonce() -> String { #[derive(Debug, Serialize)] pub struct SsoProviderInfo { - pub provider: String, + pub provider: SsoProviderType, pub name: String, pub icon: String, } @@ -52,7 +52,7 @@ pub async fn get_sso_providers(State(state): State) -> Json) -> Json, - pub action: Option, + pub action: Option, } #[derive(Debug, Serialize)] @@ -79,34 +79,20 @@ pub async fn sso_initiate( headers: HeaderMap, Json(input): Json, ) -> Result, ApiError> { - if input.provider.len() > 20 { - return Err(ApiError::SsoProviderNotFound); - } if let Some(ref uri) = input.request_uri && uri.len() > 500 { return Err(ApiError::InvalidRequest("Request URI too long".into())); } - if let Some(ref action) = input.action - && action.len() > 20 - { - return Err(ApiError::SsoInvalidAction); - } - let provider_type = - SsoProviderType::parse(&input.provider).ok_or(ApiError::SsoProviderNotFound)?; + let provider_type = input.provider; let provider = state .sso_manager .get_provider(provider_type) .ok_or(ApiError::SsoProviderNotEnabled)?; - let action = input - .action - .as_deref() - .map(SsoAction::parse) - .unwrap_or(Some(SsoAction::Login)) - .ok_or(ApiError::SsoInvalidAction)?; + let action = input.action.unwrap_or(SsoAction::Login); let is_standalone = action == SsoAction::Register && input.request_uri.is_none(); let request_uri = input @@ -616,7 +602,7 @@ async fn handle_sso_register( #[derive(Debug, Serialize)] pub struct LinkedAccountInfo { pub id: String, - pub provider: String, + pub provider: SsoProviderType, pub provider_name: String, pub provider_username: Option, pub provider_email: Option, @@ -642,7 +628,7 @@ pub async fn get_linked_accounts( .into_iter() .map(|id| LinkedAccountInfo { id: id.id.to_string(), - provider: id.provider.as_str().to_string(), + provider: id.provider, provider_name: id.provider.display_name().to_string(), provider_username: id.provider_username.map(|u| u.into_inner()), provider_email: id.provider_email.map(|e| e.into_inner()), @@ -723,7 +709,7 @@ pub struct PendingRegistrationQuery { #[derive(Debug, Serialize)] pub struct PendingRegistrationResponse { pub request_uri: String, - pub provider: String, + pub provider: SsoProviderType, pub provider_user_id: String, pub provider_username: Option, pub provider_email: Option, @@ -747,7 +733,7 @@ pub async fn get_pending_registration( Ok(Json(PendingRegistrationResponse { request_uri: pending.request_uri, - provider: pending.provider.as_str().to_string(), + provider: pending.provider, provider_user_id: pending.provider_user_id.into_inner(), provider_username: pending.provider_username.map(|u| u.into_inner()), provider_email: pending.provider_email.map(|e| e.into_inner()), @@ -789,7 +775,10 @@ pub async fn check_handle_available( let hostname_for_handles = pds_hostname_without_port(); let full_handle = format!("{}.{}", validated, hostname_for_handles); - let handle_typed = unsafe { crate::types::Handle::new_unchecked(&full_handle) }; + let handle_typed: crate::types::Handle = match full_handle.parse() { + Ok(h) => h, + Err(_) => return Err(ApiError::InvalidHandle(None)), + }; let db_available = state .user_repo @@ -816,7 +805,7 @@ pub struct CompleteRegistrationInput { pub handle: String, pub email: Option, pub invite_code: Option, - pub verification_channel: Option, + pub verification_channel: Option, pub discord_username: Option, pub telegram_username: Option, pub signal_username: Option, @@ -875,9 +864,11 @@ pub async fn complete_registration( Err(_) => return Err(ApiError::InvalidHandle(None)), }; - let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); + let verification_channel = input + .verification_channel + .unwrap_or(tranquil_db_traits::CommsChannel::Email); let verification_recipient = match verification_channel { - "email" => { + tranquil_db_traits::CommsChannel::Email => { let email = input .email .clone() @@ -894,7 +885,7 @@ pub async fn complete_registration( _ => return Err(ApiError::MissingEmail), } } - "discord" => match &input.discord_username { + tranquil_db_traits::CommsChannel::Discord => match &input.discord_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().to_lowercase(); if !crate::api::validation::is_valid_discord_username(&clean) { @@ -906,7 +897,7 @@ pub async fn complete_registration( } _ => return Err(ApiError::MissingDiscordId), }, - "telegram" => match &input.telegram_username { + tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username { Some(username) if !username.trim().is_empty() => { let clean = username.trim().trim_start_matches('@'); if !crate::api::validation::is_valid_telegram_username(clean) { @@ -918,13 +909,12 @@ pub async fn complete_registration( } _ => return Err(ApiError::MissingTelegramUsername), }, - "signal" => match &input.signal_username { + tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { Some(username) if !username.trim().is_empty() => { username.trim().trim_start_matches('@').to_lowercase() } _ => return Err(ApiError::MissingSignalNumber), }, - _ => return Err(ApiError::InvalidVerificationChannel), }; let email = input @@ -958,16 +948,15 @@ pub async fn complete_registration( Err(_) => return Err(ApiError::InvalidInviteCode), } } else { - let invite_required = std::env::var("INVITE_CODE_REQUIRED") - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + let invite_required = crate::util::parse_env_bool("INVITE_CODE_REQUIRED"); if invite_required { return Err(ApiError::InviteCodeRequired); } None }; - let handle_typed = unsafe { crate::types::Handle::new_unchecked(&handle) }; + let handle_typed: crate::types::Handle = + handle.parse().map_err(|_| ApiError::InvalidHandle(None))?; let reserved = state .user_repo .reserve_handle(&handle_typed, client_ip) @@ -1069,7 +1058,9 @@ pub async fn complete_registration( }; let rev = Tid::now(LimitedU32::MIN); - let did_typed = unsafe { crate::types::Did::new_unchecked(&did) }; + let did_typed: crate::types::Did = did + .parse() + .map_err(|_| ApiError::InternalError(Some("Invalid DID".into())))?; let (commit_bytes, _sig) = match crate::api::repo::record::utils::create_signed_commit( &did_typed, mst_root, @@ -1101,19 +1092,11 @@ pub async fn complete_registration( }) }); - let preferred_comms_channel = match verification_channel { - "email" => tranquil_db_traits::CommsChannel::Email, - "discord" => tranquil_db_traits::CommsChannel::Discord, - "telegram" => tranquil_db_traits::CommsChannel::Telegram, - "signal" => tranquil_db_traits::CommsChannel::Signal, - _ => tranquil_db_traits::CommsChannel::Email, - }; - let create_input = tranquil_db_traits::CreateSsoAccountInput { handle: handle_typed.clone(), email: email.clone(), did: did_typed.clone(), - preferred_comms_channel, + preferred_comms_channel: verification_channel, discord_username: input .discord_username .clone() @@ -1192,13 +1175,11 @@ pub async fn complete_registration( "$type": "app.bsky.actor.profile", "displayName": handle_typed.as_str() }); - let profile_collection = unsafe { crate::types::Nsid::new_unchecked("app.bsky.actor.profile") }; - let profile_rkey = unsafe { crate::types::Rkey::new_unchecked("self") }; if let Err(e) = crate::api::repo::record::create_record_internal( &state, &did_typed, - &profile_collection, - &profile_rkey, + &crate::types::PROFILE_COLLECTION, + &crate::types::PROFILE_RKEY, &profile_record, ) .await @@ -1261,7 +1242,7 @@ pub async fn complete_registration( .await .unwrap_or(None); - let channel_auto_verified = verification_channel == "email" + let channel_auto_verified = verification_channel == tranquil_db_traits::CommsChannel::Email && pending_preview.provider_email_verified && pending_preview.provider_email.as_ref().map(|e| e.as_str()) == email.as_deref(); @@ -1357,7 +1338,7 @@ pub async fn complete_registration( if let Some(uid) = user_id { let verification_token = crate::auth::verification_token::generate_signup_token( - &did, + &did_typed, verification_channel, &verification_recipient, ); diff --git a/crates/tranquil-pds/src/sso/providers.rs b/crates/tranquil-pds/src/sso/providers.rs index 896ebed..2ced382 100644 --- a/crates/tranquil-pds/src/sso/providers.rs +++ b/crates/tranquil-pds/src/sso/providers.rs @@ -14,6 +14,16 @@ use super::config::{AppleProviderConfig, ProviderConfig, SsoConfig}; const SSO_HTTP_TIMEOUT: Duration = Duration::from_secs(15); +struct PkceChallenge { + code_verifier: String, + code_challenge: String, +} + +struct ClientSecretWithExpiry { + secret: String, + expires_at: u64, +} + fn create_http_client() -> Client { Client::builder() .timeout(SSO_HTTP_TIMEOUT) @@ -473,17 +483,20 @@ impl OidcProvider { .await } - fn generate_pkce() -> (String, String) { + fn generate_pkce() -> PkceChallenge { use rand::RngCore; let mut verifier_bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut verifier_bytes); - let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); use sha2::{Digest, Sha256}; - let challenge_bytes = Sha256::digest(verifier.as_bytes()); - let challenge = URL_SAFE_NO_PAD.encode(challenge_bytes); + let challenge_bytes = Sha256::digest(code_verifier.as_bytes()); + let code_challenge = URL_SAFE_NO_PAD.encode(challenge_bytes); - (verifier, challenge) + PkceChallenge { + code_verifier, + code_challenge, + } } fn validate_id_token( @@ -585,7 +598,7 @@ impl SsoProvider for OidcProvider { redirect_uri: &str, nonce: Option<&str>, ) -> Result { - let (verifier, challenge) = Self::generate_pkce(); + let pkce = Self::generate_pkce(); let auth_endpoint = match self.provider_type { SsoProviderType::Google => "https://accounts.google.com/o/oauth2/v2/auth".to_string(), @@ -604,7 +617,7 @@ impl SsoProvider for OidcProvider { urlencoding::encode(&self.client_id), urlencoding::encode(redirect_uri), urlencoding::encode(state), - urlencoding::encode(&challenge), + urlencoding::encode(&pkce.code_challenge), ); if let Some(n) = nonce { @@ -613,7 +626,7 @@ impl SsoProvider for OidcProvider { Ok(AuthUrlResult { url, - code_verifier: Some(verifier), + code_verifier: Some(pkce.code_verifier), }) } @@ -785,10 +798,10 @@ impl AppleProvider { }) } - fn generate_client_secret(&self) -> Result<(String, u64), SsoError> { + fn generate_client_secret(&self) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); let exp = now + (150 * 24 * 60 * 60); @@ -821,13 +834,16 @@ impl AppleProvider { SsoError::Provider(format!("Failed to generate Apple client secret: {}", e)) })?; - Ok((token, exp)) + Ok(ClientSecretWithExpiry { + secret: token, + expires_at: exp, + }) } async fn get_client_secret(&self) -> Result { let now = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .unwrap_or_default() .as_secs(); { @@ -839,17 +855,17 @@ impl AppleProvider { } } - let (secret, expires_at) = self.generate_client_secret()?; + let generated = self.generate_client_secret()?; { let mut cache = self.client_secret_cache.write().await; *cache = Some(CachedClientSecret { - secret: secret.clone(), - expires_at, + secret: generated.secret.clone(), + expires_at: generated.expires_at, }); } - Ok(secret) + Ok(generated.secret) } async fn get_jwks(&self) -> Result<&JwkSet, SsoError> { diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 6ebfe12..ec2642a 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -61,6 +61,12 @@ pub struct AppState { pub shutdown: CancellationToken, } +#[derive(Debug, Clone, Copy)] +pub struct RateLimitParams { + pub limit: u32, + pub window_ms: u64, +} + #[derive(Debug, Clone, Copy)] pub enum RateLimitKind { Login, @@ -86,7 +92,7 @@ pub enum RateLimitKind { } impl RateLimitKind { - fn key_prefix(&self) -> &'static str { + const fn key_prefix(&self) -> &'static str { match self { Self::Login => "login", Self::AccountCreation => "account_creation", @@ -111,28 +117,88 @@ impl RateLimitKind { } } - fn limit_and_window_ms(&self) -> (u32, u64) { + const fn params(&self) -> RateLimitParams { match self { - Self::Login => (10, 60_000), - Self::AccountCreation => (10, 3_600_000), - Self::PasswordReset => (5, 3_600_000), - Self::ResetPassword => (10, 60_000), - Self::RefreshSession => (60, 60_000), - Self::OAuthToken => (300, 60_000), - Self::OAuthAuthorize => (10, 60_000), - Self::OAuthPar => (30, 60_000), - Self::OAuthIntrospect => (30, 60_000), - Self::AppPassword => (10, 60_000), - Self::EmailUpdate => (5, 3_600_000), - Self::TotpVerify => (5, 300_000), - Self::HandleUpdate => (10, 300_000), - Self::HandleUpdateDaily => (50, 86_400_000), - Self::VerificationCheck => (60, 60_000), - Self::SsoInitiate => (10, 60_000), - Self::SsoCallback => (30, 60_000), - Self::SsoUnlink => (10, 60_000), - Self::OAuthRegisterComplete => (5, 300_000), - Self::HandleVerification => (10, 60_000), + Self::Login => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::AccountCreation => RateLimitParams { + limit: 10, + window_ms: 3_600_000, + }, + Self::PasswordReset => RateLimitParams { + limit: 5, + window_ms: 3_600_000, + }, + Self::ResetPassword => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::RefreshSession => RateLimitParams { + limit: 60, + window_ms: 60_000, + }, + Self::OAuthToken => RateLimitParams { + limit: 300, + window_ms: 60_000, + }, + Self::OAuthAuthorize => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::OAuthPar => RateLimitParams { + limit: 30, + window_ms: 60_000, + }, + Self::OAuthIntrospect => RateLimitParams { + limit: 30, + window_ms: 60_000, + }, + Self::AppPassword => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::EmailUpdate => RateLimitParams { + limit: 5, + window_ms: 3_600_000, + }, + Self::TotpVerify => RateLimitParams { + limit: 5, + window_ms: 300_000, + }, + Self::HandleUpdate => RateLimitParams { + limit: 10, + window_ms: 300_000, + }, + Self::HandleUpdateDaily => RateLimitParams { + limit: 50, + window_ms: 86_400_000, + }, + Self::VerificationCheck => RateLimitParams { + limit: 60, + window_ms: 60_000, + }, + Self::SsoInitiate => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::SsoCallback => RateLimitParams { + limit: 30, + window_ms: 60_000, + }, + Self::SsoUnlink => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, + Self::OAuthRegisterComplete => RateLimitParams { + limit: 5, + window_ms: 300_000, + }, + Self::HandleVerification => RateLimitParams { + limit: 10, + window_ms: 60_000, + }, } } } @@ -294,11 +360,11 @@ impl AppState { } let key = format!("{}:{}", kind.key_prefix(), client_ip); - let (limit, window_ms) = kind.limit_and_window_ms(); + let params = kind.params(); if !self .distributed_rate_limiter - .check_rate_limit(&key, limit, window_ms) + .check_rate_limit(&key, params.limit, params.window_ms) .await { crate::metrics::record_rate_limit_rejection(limiter_name); diff --git a/crates/tranquil-pds/src/sync/blob.rs b/crates/tranquil-pds/src/sync/blob.rs index 4543953..47e5aab 100644 --- a/crates/tranquil-pds/src/sync/blob.rs +++ b/crates/tranquil-pds/src/sync/blob.rs @@ -1,6 +1,6 @@ use crate::api::error::ApiError; use crate::state::AppState; -use crate::sync::util::assert_repo_availability; +use crate::sync::util::{RepoAccessLevel, assert_repo_availability}; use axum::{ Json, body::Body, @@ -15,35 +15,24 @@ use tranquil_types::{CidLink, Did}; #[derive(Deserialize)] pub struct GetBlobParams { - pub did: String, - pub cid: String, + pub did: Did, + pub cid: CidLink, } pub async fn get_blob( State(state): State, Query(params): Query, ) -> Response { - let did_str = params.did.trim(); - let cid_str = params.cid.trim(); - if did_str.is_empty() { - return ApiError::InvalidRequest("did is required".into()).into_response(); - } - if cid_str.is_empty() { - return ApiError::InvalidRequest("cid is required".into()).into_response(); - } - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; - let cid: CidLink = match cid_str.parse() { - Ok(c) => c, - Err(_) => return ApiError::InvalidRequest("invalid cid".into()).into_response(), - }; + let did = params.did; + let cid = params.cid; - let _account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let _account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let blob_result = state.blob_repo.get_blob_metadata(&cid).await; match blob_result { @@ -55,7 +44,7 @@ pub async fn get_blob( .header("x-content-type-options", "nosniff") .header("content-security-policy", "default-src 'none'; sandbox") .body(Body::from(data)) - .unwrap(), + .unwrap_or_else(|_| ApiError::InternalError(None).into_response()), Err(e) => { error!("Failed to fetch blob from storage: {:?}", e); ApiError::BlobNotFound(Some("Blob not found in storage".into())).into_response() @@ -71,7 +60,7 @@ pub async fn get_blob( #[derive(Deserialize)] pub struct ListBlobsParams { - pub did: String, + pub did: Did, pub since: Option, pub limit: Option, pub cursor: Option, @@ -88,19 +77,15 @@ pub async fn list_blobs( State(state): State, Query(params): Query, ) -> Response { - let did_str = params.did.trim(); - if did_str.is_empty() { - return ApiError::InvalidRequest("did is required".into()).into_response(); - } - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; + let did = params.did; - let account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let limit = params.limit.unwrap_or(500).clamp(1, 1000); let cursor_cid = params.cursor.as_deref().unwrap_or(""); @@ -117,7 +102,7 @@ pub async fn list_blobs( cid_strs .into_iter() .filter(|c| c.as_str() > cursor_cid) - .take((limit + 1) as usize) + .take(usize::try_from(limit + 1).unwrap_or(0)) .collect() }) } else { @@ -129,8 +114,9 @@ pub async fn list_blobs( }; match cids_result { Ok(cids) => { - let has_more = cids.len() as i64 > limit; - let cids: Vec = cids.into_iter().take(limit as usize).collect(); + let limit_usize = usize::try_from(limit).unwrap_or(0); + let has_more = cids.len() > limit_usize; + let cids: Vec = cids.into_iter().take(limit_usize).collect(); let next_cursor = if has_more { cids.last().cloned() } else { None }; ( StatusCode::OK, diff --git a/crates/tranquil-pds/src/sync/car.rs b/crates/tranquil-pds/src/sync/car.rs index 774c04e..745c525 100644 --- a/crates/tranquil-pds/src/sync/car.rs +++ b/crates/tranquil-pds/src/sync/car.rs @@ -2,6 +2,21 @@ use cid::Cid; use iroh_car::CarHeader; use std::io::Write; +#[derive(Debug)] +pub enum CarEncodeError { + CborEncodeFailed(String), +} + +impl std::fmt::Display for CarEncodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CborEncodeFailed(e) => write!(f, "Failed to encode CAR header: {}", e), + } + } +} + +impl std::error::Error for CarEncodeError {} + pub fn write_varint(mut writer: W, mut value: u64) -> std::io::Result<()> { loop { let mut byte = (value & 0x7F) as u8; @@ -18,31 +33,45 @@ pub fn write_varint(mut writer: W, mut value: u64) -> std::io::Result< } pub fn ld_write(mut writer: W, data: &[u8]) -> std::io::Result<()> { - write_varint(&mut writer, data.len() as u64)?; + write_varint( + &mut writer, + u64::try_from(data.len()).expect("len fits u64"), + )?; writer.write_all(data)?; Ok(()) } -pub fn encode_car_header(root_cid: &Cid) -> Result, String> { - let header = CarHeader::new_v1(vec![*root_cid]); +pub fn encode_car_header_with_root(root_cid: Option<&Cid>) -> Result, CarEncodeError> { + let roots = root_cid.map_or_else(Vec::new, |cid| vec![*cid]); + let header = CarHeader::new_v1(roots); let header_cbor = header .encode() - .map_err(|e| format!("Failed to encode CAR header: {:?}", e))?; + .map_err(|e| CarEncodeError::CborEncodeFailed(format!("{:?}", e)))?; let mut result = Vec::new(); - write_varint(&mut result, header_cbor.len() as u64) - .expect("Writing to Vec should never fail"); + write_varint( + &mut result, + u64::try_from(header_cbor.len()).expect("len fits u64"), + ) + .expect("Writing to Vec should never fail"); result.extend_from_slice(&header_cbor); Ok(result) } -pub fn encode_car_header_null_root() -> Result, String> { - let header = CarHeader::new_v1(vec![]); - let header_cbor = header - .encode() - .map_err(|e| format!("Failed to encode CAR header: {:?}", e))?; - let mut result = Vec::new(); - write_varint(&mut result, header_cbor.len() as u64) - .expect("Writing to Vec should never fail"); - result.extend_from_slice(&header_cbor); - Ok(result) +pub fn encode_car_header(root_cid: &Cid) -> Result, CarEncodeError> { + encode_car_header_with_root(Some(root_cid)) +} + +pub fn encode_car_header_null_root() -> Result, CarEncodeError> { + encode_car_header_with_root(None) +} + +pub fn encode_car_block(cid: &Cid, block: &[u8]) -> Vec { + let cid_bytes = cid.to_bytes(); + let total_len = cid_bytes.len() + block.len(); + let mut buf = Vec::with_capacity(10 + total_len); + write_varint(&mut buf, u64::try_from(total_len).unwrap_or(u64::MAX)) + .unwrap_or_else(|_| unreachable!()); + buf.extend_from_slice(&cid_bytes); + buf.extend_from_slice(block); + buf } diff --git a/crates/tranquil-pds/src/sync/commit.rs b/crates/tranquil-pds/src/sync/commit.rs index 07332ac..d206763 100644 --- a/crates/tranquil-pds/src/sync/commit.rs +++ b/crates/tranquil-pds/src/sync/commit.rs @@ -1,6 +1,6 @@ use crate::api::error::ApiError; use crate::state::AppState; -use crate::sync::util::{assert_repo_availability, get_account_with_status}; +use crate::sync::util::{RepoAccessLevel, assert_repo_availability, get_account_with_status}; use axum::{ Json, extract::{Query, State}, @@ -25,7 +25,7 @@ async fn get_rev_from_commit(state: &AppState, cid_str: &str) -> Option #[derive(Deserialize)] pub struct GetLatestCommitParams { - pub did: String, + pub did: Did, } #[derive(Serialize)] @@ -38,19 +38,15 @@ pub async fn get_latest_commit( State(state): State, Query(params): Query, ) -> Response { - let did_str = params.did.trim(); - if did_str.is_empty() { - return ApiError::InvalidRequest("did is required".into()).into_response(); - } - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; + let did = params.did; - let account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let Some(repo_root_cid) = account.repo_root_cid else { return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); @@ -59,7 +55,7 @@ pub async fn get_latest_commit( let Some(rev) = get_rev_from_commit(&state, &repo_root_cid).await else { error!( "Failed to parse commit for DID {}: CID {}", - did_str, repo_root_cid + did, repo_root_cid ); return ApiError::InternalError(Some("Failed to read repo commit".into())).into_response(); }; @@ -83,12 +79,12 @@ pub struct ListReposParams { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RepoInfo { - pub did: String, + pub did: Did, pub head: String, pub rev: String, pub active: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, + pub status: Option, } #[derive(Serialize)] @@ -111,9 +107,10 @@ pub async fn list_repos( .await; match result { Ok(rows) => { - let has_more = rows.len() as i64 > limit; + let limit_usize = usize::try_from(limit).unwrap_or(0); + let has_more = rows.len() > limit_usize; let mut repos: Vec = Vec::new(); - for row in rows.iter().take(limit as usize) { + for row in rows.iter().take(limit_usize) { let cid_str = row.repo_root_cid.to_string(); let rev = get_rev_from_commit(&state, &cid_str) .await @@ -127,15 +124,15 @@ pub async fn list_repos( AccountStatus::Active }; repos.push(RepoInfo { - did: row.did.to_string(), + did: row.did.clone(), head: cid_str, rev, active: status.is_active(), - status: status.for_firehose().map(String::from), + status: status.for_firehose_typed(), }); } let next_cursor = if has_more { - repos.last().map(|r| r.did.clone()) + repos.last().map(|r| r.did.to_string()) } else { None }; @@ -157,15 +154,15 @@ pub async fn list_repos( #[derive(Deserialize)] pub struct GetRepoStatusParams { - pub did: String, + pub did: Did, } #[derive(Serialize)] pub struct GetRepoStatusOutput { - pub did: String, + pub did: Did, pub active: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, + pub status: Option, #[serde(skip_serializing_if = "Option::is_none")] pub rev: Option, } @@ -174,23 +171,13 @@ pub async fn get_repo_status( State(state): State, Query(params): Query, ) -> Response { - let did_str = params.did.trim(); - if did_str.is_empty() { - return ApiError::InvalidRequest("did is required".into()).into_response(); - } - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; + let did = params.did; let account = match get_account_with_status(state.repo_repo.as_ref(), &did).await { Ok(Some(a)) => a, Ok(None) => { - return ApiError::RepoNotFound(Some(format!( - "Could not find repo for DID: {}", - did_str - ))) - .into_response(); + return ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) + .into_response(); } Err(e) => { error!("DB error in get_repo_status: {:?}", e); @@ -213,7 +200,7 @@ pub async fn get_repo_status( Json(GetRepoStatusOutput { did: account.did, active: account.status.is_active(), - status: account.status.for_firehose().map(String::from), + status: account.status.for_firehose_typed(), rev, }), ) diff --git a/crates/tranquil-pds/src/sync/deprecated.rs b/crates/tranquil-pds/src/sync/deprecated.rs index e9aa390..6164075 100644 --- a/crates/tranquil-pds/src/sync/deprecated.rs +++ b/crates/tranquil-pds/src/sync/deprecated.rs @@ -1,47 +1,45 @@ use crate::api::error::ApiError; use crate::state::AppState; -use crate::sync::car::encode_car_header; -use crate::sync::util::assert_repo_availability; +use crate::sync::car::{encode_car_block, encode_car_header}; +use crate::sync::util::{RepoAccessLevel, assert_repo_availability}; use axum::{ Json, extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, Method, StatusCode}, response::{IntoResponse, Response}, }; use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use std::io::Write; use std::str::FromStr; use tranquil_types::Did; const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; -async fn check_admin_or_self(state: &AppState, headers: &HeaderMap, did: &str) -> bool { +async fn check_admin_or_self(state: &AppState, headers: &HeaderMap, did: &Did) -> bool { let extracted = match crate::auth::extract_auth_token_from_header(crate::util::get_header_str( headers, - "Authorization", + axum::http::header::AUTHORIZATION, )) { Some(t) => t, None => return false, }; - let dpop_proof = crate::util::get_header_str(headers, "DPoP"); + let dpop_proof = crate::util::get_header_str(headers, crate::util::HEADER_DPOP); let http_uri = "/"; match crate::auth::validate_token_with_dpop( state.user_repo.as_ref(), state.oauth_repo.as_ref(), &extracted.token, - extracted.is_dpop, + extracted.scheme, dpop_proof, - "GET", + Method::GET.as_str(), http_uri, - false, - true, + crate::auth::AccountRequirement::AnyStatus, ) .await { - Ok(auth_user) => auth_user.is_admin || auth_user.did == did, + Ok(auth_user) => auth_user.is_admin || auth_user.did == *did, Err(_) => false, } } @@ -69,15 +67,24 @@ pub async fn get_head( Ok(d) => d, Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), }; - let is_admin_or_self = check_admin_or_self(&state, &headers, did_str).await; - let account = - match assert_repo_availability(state.repo_repo.as_ref(), &did, is_admin_or_self).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let is_admin_or_self = check_admin_or_self(&state, &headers, &did).await; + let account = match assert_repo_availability( + state.repo_repo.as_ref(), + &did, + if is_admin_or_self { + RepoAccessLevel::Privileged + } else { + RepoAccessLevel::Public + }, + ) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; match account.repo_root_cid { Some(root) => (StatusCode::OK, Json(GetHeadOutput { root })).into_response(), - None => ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did_str))) + None => ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did))) .into_response(), } } @@ -100,12 +107,21 @@ pub async fn get_checkout( Ok(d) => d, Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), }; - let is_admin_or_self = check_admin_or_self(&state, &headers, did_str).await; - let account = - match assert_repo_availability(state.repo_repo.as_ref(), &did, is_admin_or_self).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let is_admin_or_self = check_admin_or_self(&state, &headers, &did).await; + let account = match assert_repo_availability( + state.repo_repo.as_ref(), + &did, + if is_admin_or_self { + RepoAccessLevel::Privileged + } else { + RepoAccessLevel::Public + }, + ) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let Some(head_str) = account.repo_root_cid else { return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; @@ -128,18 +144,7 @@ pub async fn get_checkout( } remaining -= 1; if let Ok(Some(block)) = state.block_store.get(&cid).await { - let cid_bytes = cid.to_bytes(); - let total_len = cid_bytes.len() + block.len(); - let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64) - .expect("Writing to Vec should never fail"); - writer - .write_all(&cid_bytes) - .expect("Writing to Vec should never fail"); - writer - .write_all(&block) - .expect("Writing to Vec should never fail"); - car_bytes.extend_from_slice(&writer); + car_bytes.extend_from_slice(&encode_car_block(&cid, &block)); if let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { extract_links_ipld(&value, &mut stack); } diff --git a/crates/tranquil-pds/src/sync/frame.rs b/crates/tranquil-pds/src/sync/frame.rs index 36e2346..879b6c3 100644 --- a/crates/tranquil-pds/src/sync/frame.rs +++ b/crates/tranquil-pds/src/sync/frame.rs @@ -3,11 +3,26 @@ use cid::Cid; use serde::{Deserialize, Serialize}; use std::str::FromStr; use tranquil_scopes::RepoAction; +use tranquil_types::Did; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameType { + #[serde(rename = "#commit")] + Commit, + #[serde(rename = "#identity")] + Identity, + #[serde(rename = "#account")] + Account, + #[serde(rename = "#sync")] + Sync, + #[serde(rename = "#info")] + Info, +} #[derive(Debug, Serialize, Deserialize)] pub struct FrameHeader { pub op: i64, - pub t: String, + pub t: FrameType, } #[derive(Debug, Serialize, Deserialize)] @@ -16,7 +31,7 @@ pub struct CommitFrame { pub rebase: bool, #[serde(rename = "tooBig")] pub too_big: bool, - pub repo: String, + pub repo: Did, pub commit: Cid, pub rev: String, pub since: Option, @@ -48,7 +63,7 @@ pub struct RepoOp { #[derive(Debug, Serialize, Deserialize)] pub struct IdentityFrame { - pub did: String, + pub did: Did, #[serde(skip_serializing_if = "Option::is_none")] pub handle: Option, pub seq: i64, @@ -57,17 +72,17 @@ pub struct IdentityFrame { #[derive(Debug, Serialize, Deserialize)] pub struct AccountFrame { - pub did: String, + pub did: Did, pub active: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub status: Option, + pub status: Option, pub seq: i64, pub time: String, } #[derive(Debug, Serialize, Deserialize)] pub struct SyncFrame { - pub did: String, + pub did: Did, pub rev: String, #[serde(with = "serde_bytes")] pub blocks: Vec, @@ -75,9 +90,21 @@ pub struct SyncFrame { pub time: String, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum InfoFrameName { + #[serde(rename = "OutdatedCursor")] + OutdatedCursor, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum ErrorFrameName { + #[serde(rename = "FutureCursor")] + FutureCursor, +} + #[derive(Debug, Serialize, Deserialize)] pub struct InfoFrame { - pub name: String, + pub name: InfoFrameName, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, } @@ -89,7 +116,7 @@ pub struct ErrorFrameHeader { #[derive(Debug, Serialize, Deserialize)] pub struct ErrorFrameBody { - pub error: String, + pub error: ErrorFrameName, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, } @@ -113,7 +140,7 @@ impl std::error::Error for CommitFrameError {} pub struct CommitFrameBuilder { seq: i64, - did: String, + did: Did, commit_cid: Cid, prev_cid: Option, ops_json: serde_json::Value, @@ -126,7 +153,7 @@ impl CommitFrameBuilder { #[allow(clippy::too_many_arguments)] pub fn new( seq: i64, - did: String, + did: Did, commit_cid_str: &str, prev_cid_str: Option<&str>, ops_json: serde_json::Value, @@ -206,7 +233,7 @@ impl TryFrom for CommitFrame { })?; let builder = CommitFrameBuilder::new( event.seq.as_i64(), - event.did.to_string(), + event.did.clone(), commit_cid.as_str(), event.prev_cid.as_ref().map(|c| c.as_str()), event.ops.unwrap_or_default(), diff --git a/crates/tranquil-pds/src/sync/import.rs b/crates/tranquil-pds/src/sync/import.rs index 3ccc38e..e8fe823 100644 --- a/crates/tranquil-pds/src/sync/import.rs +++ b/crates/tranquil-pds/src/sync/import.rs @@ -210,12 +210,9 @@ fn walk_mst_node( if let Ipld::Map(entry_obj) = entry { let prefix_len = entry_obj .get("p") - .and_then(|p| { - if let Ipld::Integer(n) = p { - Some(*n as usize) - } else { - None - } + .and_then(|p| match p { + Ipld::Integer(n) => usize::try_from(*n).ok(), + _ => None, }) .unwrap_or(0); diff --git a/crates/tranquil-pds/src/sync/mod.rs b/crates/tranquil-pds/src/sync/mod.rs index ae964b5..f89e35e 100644 --- a/crates/tranquil-pds/src/sync/mod.rs +++ b/crates/tranquil-pds/src/sync/mod.rs @@ -20,6 +20,7 @@ pub use repo::{get_blocks, get_record, get_repo}; pub use subscribe_repos::subscribe_repos; pub use tranquil_db_traits::AccountStatus; pub use util::{ - RepoAccount, RepoAvailabilityError, assert_repo_availability, get_account_with_status, + RepoAccessLevel, RepoAccount, RepoAvailabilityError, assert_repo_availability, + get_account_with_status, }; pub use verify::{CarVerifier, VerifiedCar, VerifyError}; diff --git a/crates/tranquil-pds/src/sync/repo.rs b/crates/tranquil-pds/src/sync/repo.rs index 220eeaa..40afaa4 100644 --- a/crates/tranquil-pds/src/sync/repo.rs +++ b/crates/tranquil-pds/src/sync/repo.rs @@ -1,8 +1,8 @@ use crate::api::error::ApiError; use crate::scheduled::generate_repo_car_from_user_blocks; use crate::state::AppState; -use crate::sync::car::encode_car_header; -use crate::sync::util::assert_repo_availability; +use crate::sync::car::{encode_car_block, encode_car_header}; +use crate::sync::util::{RepoAccessLevel, assert_repo_availability}; use axum::{ extract::{Query, RawQuery, State}, http::StatusCode, @@ -11,18 +11,25 @@ use axum::{ use cid::Cid; use jacquard_repo::storage::BlockStore; use serde::Deserialize; -use std::io::Write; use std::str::FromStr; use tracing::error; use tranquil_types::Did; -fn parse_get_blocks_query(query_string: &str) -> Result<(String, Vec), String> { - let did = crate::util::parse_repeated_query_param(Some(query_string), "did") +struct GetBlocksParams { + did: Did, + cids: Vec, +} + +fn parse_get_blocks_query(query_string: &str) -> Result { + let did_str = crate::util::parse_repeated_query_param(Some(query_string), "did") .into_iter() .next() - .ok_or("Missing required parameter: did")?; + .ok_or_else(|| ApiError::InvalidRequest("Missing required parameter: did".into()))?; + let did: Did = did_str + .parse() + .map_err(|_| ApiError::InvalidRequest("invalid did".into()))?; let cids = crate::util::parse_repeated_query_param(Some(query_string), "cids"); - Ok((did, cids)) + Ok(GetBlocksParams { did, cids }) } pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery) -> Response { @@ -30,20 +37,22 @@ pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery return ApiError::InvalidRequest("Missing query parameters".into()).into_response(); }; - let (did_str, cid_strings) = match parse_get_blocks_query(&query_string) { + let GetBlocksParams { + did, + cids: cid_strings, + } = match parse_get_blocks_query(&query_string) { Ok(parsed) => parsed, - Err(msg) => return ApiError::InvalidRequest(msg).into_response(), - }; - let did: Did = match did_str.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; - - let _account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, Err(e) => return e.into_response(), }; + let _account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; + let cids: Vec = match cid_strings .iter() .map(|s| Cid::from_str(s).map_err(|_| s.clone())) @@ -89,23 +98,11 @@ pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery } }; let mut car_bytes = header; - for (i, block_opt) in blocks.into_iter().enumerate() { - if let Some(block) = block_opt { - let cid = cids[i]; - let cid_bytes = cid.to_bytes(); - let total_len = cid_bytes.len() + block.len(); - let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64) - .expect("Writing to Vec should never fail"); - writer - .write_all(&cid_bytes) - .expect("Writing to Vec should never fail"); - writer - .write_all(&block) - .expect("Writing to Vec should never fail"); - car_bytes.extend_from_slice(&writer); - } - } + blocks + .into_iter() + .enumerate() + .filter_map(|(i, block_opt)| block_opt.map(|block| (cids[i], block))) + .for_each(|(cid, block)| car_bytes.extend_from_slice(&encode_car_block(&cid, &block))); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car")], @@ -116,7 +113,7 @@ pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery #[derive(Deserialize)] pub struct GetRepoQuery { - pub did: String, + pub did: Did, pub since: Option, } @@ -124,14 +121,14 @@ pub async fn get_repo( State(state): State, Query(query): Query, ) -> Response { - let did: Did = match query.did.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; - let account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let did = query.did; + let account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let Some(head_str) = account.repo_root_cid else { return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); @@ -223,23 +220,11 @@ async fn get_repo_since(state: &AppState, did: &Did, head_cid: &Cid, since: &str } }; - for (i, block_opt) in blocks.into_iter().enumerate() { - if let Some(block) = block_opt { - let cid = block_cids[i]; - let cid_bytes = cid.to_bytes(); - let total_len = cid_bytes.len() + block.len(); - let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64) - .expect("Writing to Vec should never fail"); - writer - .write_all(&cid_bytes) - .expect("Writing to Vec should never fail"); - writer - .write_all(&block) - .expect("Writing to Vec should never fail"); - car_bytes.extend_from_slice(&writer); - } - } + blocks + .into_iter() + .enumerate() + .filter_map(|(i, block_opt)| block_opt.map(|block| (block_cids[i], block))) + .for_each(|(cid, block)| car_bytes.extend_from_slice(&encode_car_block(&cid, &block))); ( StatusCode::OK, @@ -251,7 +236,7 @@ async fn get_repo_since(state: &AppState, did: &Did, head_cid: &Cid, since: &str #[derive(Deserialize)] pub struct GetRecordQuery { - pub did: String, + pub did: Did, pub collection: String, pub rkey: String, } @@ -265,14 +250,14 @@ pub async fn get_record( use std::collections::BTreeMap; use std::sync::Arc; - let did: Did = match query.did.parse() { - Ok(d) => d, - Err(_) => return ApiError::InvalidRequest("invalid did".into()).into_response(), - }; - let account = match assert_repo_availability(state.repo_repo.as_ref(), &did, false).await { - Ok(a) => a, - Err(e) => return e.into_response(), - }; + let did = query.did; + let account = + match assert_repo_availability(state.repo_repo.as_ref(), &did, RepoAccessLevel::Public) + .await + { + Ok(a) => a, + Err(e) => return e.into_response(), + }; let commit_cid_str = match account.repo_root_cid { Some(cid) => cid, @@ -321,25 +306,11 @@ pub async fn get_record( } }; let mut car_bytes = header; - let write_block = |car: &mut Vec, cid: &Cid, data: &[u8]| { - let cid_bytes = cid.to_bytes(); - let total_len = cid_bytes.len() + data.len(); - let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64) - .expect("Writing to Vec should never fail"); - writer - .write_all(&cid_bytes) - .expect("Writing to Vec should never fail"); - writer - .write_all(data) - .expect("Writing to Vec should never fail"); - car.extend_from_slice(&writer); - }; - write_block(&mut car_bytes, &commit_cid, &commit_bytes); + car_bytes.extend_from_slice(&encode_car_block(&commit_cid, &commit_bytes)); proof_blocks .iter() - .for_each(|(cid, data)| write_block(&mut car_bytes, cid, data)); - write_block(&mut car_bytes, &record_cid, &record_block); + .for_each(|(cid, data)| car_bytes.extend_from_slice(&encode_car_block(cid, data))); + car_bytes.extend_from_slice(&encode_car_block(&record_cid, &record_block)); ( StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car")], diff --git a/crates/tranquil-pds/src/sync/subscribe_repos.rs b/crates/tranquil-pds/src/sync/subscribe_repos.rs index 317891a..e823e80 100644 --- a/crates/tranquil-pds/src/sync/subscribe_repos.rs +++ b/crates/tranquil-pds/src/sync/subscribe_repos.rs @@ -1,5 +1,6 @@ use crate::state::AppState; use crate::sync::firehose::SequencedEvent; +use crate::sync::frame::{ErrorFrameName, InfoFrameName}; use crate::sync::util::{ format_error_frame, format_event_for_sending, format_event_with_prefetched_blocks, format_info_frame, prefetch_blocks_for_events, @@ -82,7 +83,7 @@ async fn handle_socket_inner( if cursor_seq > current_seq { if let Ok(error_bytes) = - format_error_frame("FutureCursor", Some("Cursor in the future.")) + format_error_frame(ErrorFrameName::FutureCursor, Some("Cursor in the future.")) { let _ = socket.send(Message::Binary(error_bytes.into())).await; } @@ -105,7 +106,7 @@ async fn handle_socket_inner( && event.created_at < backfill_time { if let Ok(info_bytes) = format_info_frame( - "OutdatedCursor", + InfoFrameName::OutdatedCursor, Some("Requested cursor exceeded limit. Possibly missing events"), ) { let _ = socket.send(Message::Binary(info_bytes.into())).await; @@ -161,7 +162,7 @@ async fn handle_socket_inner( } crate::metrics::record_firehose_event(); } - if (events_count as i64) < BACKFILL_BATCH_SIZE { + if i64::try_from(events_count).unwrap_or(i64::MAX) < BACKFILL_BATCH_SIZE { break; } } diff --git a/crates/tranquil-pds/src/sync/util.rs b/crates/tranquil-pds/src/sync/util.rs index 5c49f58..b4962ad 100644 --- a/crates/tranquil-pds/src/sync/util.rs +++ b/crates/tranquil-pds/src/sync/util.rs @@ -2,8 +2,8 @@ use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::firehose::SequencedEvent; use crate::sync::frame::{ - AccountFrame, CommitFrame, ErrorFrameBody, ErrorFrameHeader, FrameHeader, IdentityFrame, - InfoFrame, SyncFrame, + AccountFrame, CommitFrame, ErrorFrameBody, ErrorFrameHeader, ErrorFrameName, FrameHeader, + FrameType, IdentityFrame, InfoFrame, InfoFrameName, SyncFrame, }; use axum::response::{IntoResponse, Response}; use bytes::Bytes; @@ -18,17 +18,86 @@ use tokio::io::AsyncWriteExt; use tranquil_db_traits::{AccountStatus, RepoEventType, RepoRepository}; use tranquil_types::Did; +#[derive(Debug)] +pub enum SyncFrameError { + CarWrite(iroh_car::Error), + CarFinalize(iroh_car::Error), + IoFlush(std::io::Error), + CborSerialize(String), + MissingCommitCid, + CommitBlockNotFound, + RevExtraction, + InvalidEvent(String), + BlockStore(tranquil_db_traits::DbError), + CidParse(cid::Error), +} + +impl std::fmt::Display for SyncFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::CarWrite(e) => write!(f, "CAR block write failed: {}", e), + Self::CarFinalize(e) => write!(f, "CAR finalize failed: {}", e), + Self::IoFlush(e) => write!(f, "CAR buffer flush failed: {}", e), + Self::CborSerialize(e) => write!(f, "CBOR serialization failed: {}", e), + Self::MissingCommitCid => write!(f, "missing commit_cid"), + Self::CommitBlockNotFound => write!(f, "commit block not found"), + Self::RevExtraction => write!(f, "could not extract rev from commit"), + Self::InvalidEvent(msg) => write!(f, "invalid event: {}", msg), + Self::BlockStore(e) => write!(f, "block store error: {}", e), + Self::CidParse(e) => write!(f, "CID parse failed: {}", e), + } + } +} + +impl std::error::Error for SyncFrameError {} + +impl From> for SyncFrameError { + fn from(e: serde_ipld_dagcbor::EncodeError) -> Self { + Self::CborSerialize(e.to_string()) + } +} + +impl From> for SyncFrameError { + fn from(e: serde_ipld_dagcbor::EncodeError) -> Self { + Self::CborSerialize(e.to_string()) + } +} + +impl From for SyncFrameError { + fn from(e: cid::Error) -> Self { + Self::CidParse(e) + } +} + +impl From for SyncFrameError { + fn from(e: tranquil_db_traits::DbError) -> Self { + Self::BlockStore(e) + } +} + +impl From for SyncFrameError { + fn from(e: jacquard_repo::error::RepoError) -> Self { + Self::BlockStore(tranquil_db_traits::DbError::from_query_error(e.to_string())) + } +} + pub struct RepoAccount { - pub did: String, + pub did: Did, pub user_id: uuid::Uuid, pub status: AccountStatus, pub repo_root_cid: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepoAccessLevel { + Public, + Privileged, +} + pub enum RepoAvailabilityError { - NotFound(String), - Takendown(String), - Deactivated(String), + NotFound(Did), + Takendown(Did), + Deactivated(Did), Internal(String), } @@ -64,7 +133,7 @@ pub async fn get_account_with_status( }; RepoAccount { - did: r.did.to_string(), + did: r.did, user_id: r.user_id, status, repo_root_cid: r.repo_root_cid.map(|c| c.to_string()), @@ -75,26 +144,25 @@ pub async fn get_account_with_status( pub async fn assert_repo_availability( repo_repo: &dyn RepoRepository, did: &Did, - is_admin_or_self: bool, + access_level: RepoAccessLevel, ) -> Result { let account = get_account_with_status(repo_repo, did) .await .map_err(|e| RepoAvailabilityError::Internal(e.to_string()))?; - let did_str = did.to_string(); let account = match account { Some(a) => a, - None => return Err(RepoAvailabilityError::NotFound(did_str)), + None => return Err(RepoAvailabilityError::NotFound(did.clone())), }; - if is_admin_or_self { + if access_level == RepoAccessLevel::Privileged { return Ok(account); } match account.status { - AccountStatus::Takendown => return Err(RepoAvailabilityError::Takendown(did_str)), + AccountStatus::Takendown => return Err(RepoAvailabilityError::Takendown(did.clone())), AccountStatus::Deactivated => { - return Err(RepoAvailabilityError::Deactivated(did_str)); + return Err(RepoAvailabilityError::Deactivated(did.clone())); } _ => {} } @@ -112,7 +180,7 @@ async fn write_car_blocks( commit_cid: Cid, commit_bytes: Option, other_blocks: BTreeMap, -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { let mut buffer = Cursor::new(Vec::new()); let header = CarHeader::new_v1(vec![commit_cid]); let mut writer = CarWriter::new(header, &mut buffer); @@ -120,22 +188,16 @@ async fn write_car_blocks( writer .write(*cid, data.as_ref()) .await - .map_err(|e| anyhow::anyhow!("writing block {}: {}", cid, e))?; + .map_err(SyncFrameError::CarWrite)?; } if let Some(data) = commit_bytes { writer .write(commit_cid, data.as_ref()) .await - .map_err(|e| anyhow::anyhow!("writing commit block: {}", e))?; + .map_err(SyncFrameError::CarWrite)?; } - writer - .finish() - .await - .map_err(|e| anyhow::anyhow!("finalizing CAR: {}", e))?; - buffer - .flush() - .await - .map_err(|e| anyhow::anyhow!("flushing CAR buffer: {}", e))?; + writer.finish().await.map_err(SyncFrameError::CarFinalize)?; + buffer.flush().await.map_err(SyncFrameError::IoFlush)?; Ok(buffer.into_inner()) } @@ -143,16 +205,16 @@ fn format_atproto_time(dt: chrono::DateTime) -> String { dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() } -fn format_identity_event(event: &SequencedEvent) -> Result, anyhow::Error> { +fn format_identity_event(event: &SequencedEvent) -> Result, SyncFrameError> { let frame = IdentityFrame { - did: event.did.to_string(), + did: event.did.clone(), handle: event.handle.as_ref().map(|h| h.to_string()), seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { op: 1, - t: "#identity".to_string(), + t: FrameType::Identity, }; let mut bytes = Vec::with_capacity(256); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -160,19 +222,17 @@ fn format_identity_event(event: &SequencedEvent) -> Result, anyhow::Erro Ok(bytes) } -fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error> { +fn format_account_event(event: &SequencedEvent) -> Result, SyncFrameError> { let frame = AccountFrame { - did: event.did.to_string(), + did: event.did.clone(), active: event.active.unwrap_or(true), - status: event - .status - .and_then(|s| s.for_firehose().map(String::from)), + status: event.status.filter(|s| !s.is_active()), seq: event.seq.as_i64(), time: format_atproto_time(event.created_at), }; let header = FrameHeader { op: 1, - t: "#account".to_string(), + t: FrameType::Account, }; let mut bytes = Vec::with_capacity(256); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -192,26 +252,25 @@ fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error async fn format_sync_event( state: &AppState, event: &SequencedEvent, -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { let commit_cid_str = event .commit_cid .as_ref() - .ok_or_else(|| anyhow::anyhow!("Sync event missing commit_cid"))?; + .ok_or(SyncFrameError::MissingCommitCid)?; let commit_cid = Cid::from_str(commit_cid_str)?; let commit_bytes = state .block_store .get(&commit_cid) .await? - .ok_or_else(|| anyhow::anyhow!("Commit block not found"))?; + .ok_or(SyncFrameError::CommitBlockNotFound)?; let rev = if let Some(ref stored_rev) = event.rev { stored_rev.clone() } else { - extract_rev_from_commit_bytes(&commit_bytes) - .ok_or_else(|| anyhow::anyhow!("Could not extract rev from commit"))? + extract_rev_from_commit_bytes(&commit_bytes).ok_or(SyncFrameError::RevExtraction)? }; let car_bytes = write_car_blocks(commit_cid, Some(commit_bytes), BTreeMap::new()).await?; let frame = SyncFrame { - did: event.did.to_string(), + did: event.did.clone(), rev, blocks: car_bytes, seq: event.seq.as_i64(), @@ -219,7 +278,7 @@ async fn format_sync_event( }; let header = FrameHeader { op: 1, - t: "#sync".to_string(), + t: FrameType::Sync, }; let mut bytes = Vec::with_capacity(512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -230,7 +289,7 @@ async fn format_sync_event( pub async fn format_event_for_sending( state: &AppState, event: SequencedEvent, -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { match event.event_type { RepoEventType::Identity => return format_identity_event(&event), RepoEventType::Account => return format_account_event(&event), @@ -240,9 +299,12 @@ pub async fn format_event_for_sending( let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_link = event.prev_cid.clone(); let prev_data_cid_link = event.prev_data_cid.clone(); - let mut frame: CommitFrame = event - .try_into() - .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; + let mut frame: CommitFrame = + event + .try_into() + .map_err(|e: crate::sync::frame::CommitFrameError| { + SyncFrameError::InvalidEvent(e.to_string()) + })?; if let Some(ref pdc) = prev_data_cid_link && let Ok(cid) = Cid::from_str(pdc.as_str()) { @@ -287,7 +349,7 @@ pub async fn format_event_for_sending( frame.blocks = car_bytes; let header = FrameHeader { op: 1, - t: "#commit".to_string(), + t: FrameType::Commit, }; let mut bytes = Vec::with_capacity(frame.blocks.len() + 512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -298,7 +360,7 @@ pub async fn format_event_for_sending( pub async fn prefetch_blocks_for_events( state: &AppState, events: &[SequencedEvent], -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { let mut all_cids: Vec = events .iter() .flat_map(|event| { @@ -332,20 +394,19 @@ pub async fn prefetch_blocks_for_events( fn format_sync_event_with_prefetched( event: &SequencedEvent, prefetched: &HashMap, -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { let commit_cid_str = event .commit_cid .as_ref() - .ok_or_else(|| anyhow::anyhow!("Sync event missing commit_cid"))?; + .ok_or(SyncFrameError::MissingCommitCid)?; let commit_cid = Cid::from_str(commit_cid_str)?; let commit_bytes = prefetched .get(&commit_cid) - .ok_or_else(|| anyhow::anyhow!("Commit block not found in prefetched"))?; + .ok_or(SyncFrameError::CommitBlockNotFound)?; let rev = if let Some(ref stored_rev) = event.rev { stored_rev.clone() } else { - extract_rev_from_commit_bytes(commit_bytes) - .ok_or_else(|| anyhow::anyhow!("Could not extract rev from commit"))? + extract_rev_from_commit_bytes(commit_bytes).ok_or(SyncFrameError::RevExtraction)? }; let car_bytes = futures::executor::block_on(write_car_blocks( commit_cid, @@ -353,7 +414,7 @@ fn format_sync_event_with_prefetched( BTreeMap::new(), ))?; let frame = SyncFrame { - did: event.did.to_string(), + did: event.did.clone(), rev, blocks: car_bytes, seq: event.seq.as_i64(), @@ -361,7 +422,7 @@ fn format_sync_event_with_prefetched( }; let header = FrameHeader { op: 1, - t: "#sync".to_string(), + t: FrameType::Sync, }; let mut bytes = Vec::new(); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -372,7 +433,7 @@ fn format_sync_event_with_prefetched( pub async fn format_event_with_prefetched_blocks( event: SequencedEvent, prefetched: &HashMap, -) -> Result, anyhow::Error> { +) -> Result, SyncFrameError> { match event.event_type { RepoEventType::Identity => return format_identity_event(&event), RepoEventType::Account => return format_account_event(&event), @@ -382,9 +443,12 @@ pub async fn format_event_with_prefetched_blocks( let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); let prev_cid_link = event.prev_cid.clone(); let prev_data_cid_link = event.prev_data_cid.clone(); - let mut frame: CommitFrame = event - .try_into() - .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; + let mut frame: CommitFrame = + event + .try_into() + .map_err(|e: crate::sync::frame::CommitFrameError| { + SyncFrameError::InvalidEvent(e.to_string()) + })?; if let Some(ref pdc) = prev_data_cid_link && let Ok(cid) = Cid::from_str(pdc.as_str()) { @@ -427,7 +491,7 @@ pub async fn format_event_with_prefetched_blocks( frame.blocks = car_bytes; let header = FrameHeader { op: 1, - t: "#commit".to_string(), + t: FrameType::Commit, }; let mut bytes = Vec::with_capacity(frame.blocks.len() + 512); serde_ipld_dagcbor::to_writer(&mut bytes, &header)?; @@ -435,13 +499,16 @@ pub async fn format_event_with_prefetched_blocks( Ok(bytes) } -pub fn format_info_frame(name: &str, message: Option<&str>) -> Result, anyhow::Error> { +pub fn format_info_frame( + name: InfoFrameName, + message: Option<&str>, +) -> Result, SyncFrameError> { let header = FrameHeader { op: 1, - t: "#info".to_string(), + t: FrameType::Info, }; let frame = InfoFrame { - name: name.to_string(), + name, message: message.map(String::from), }; let mut bytes = Vec::with_capacity(128); @@ -450,10 +517,13 @@ pub fn format_info_frame(name: &str, message: Option<&str>) -> Result, a Ok(bytes) } -pub fn format_error_frame(error: &str, message: Option<&str>) -> Result, anyhow::Error> { +pub fn format_error_frame( + error: ErrorFrameName, + message: Option<&str>, +) -> Result, SyncFrameError> { let header = ErrorFrameHeader { op: -1 }; let frame = ErrorFrameBody { - error: error.to_string(), + error, message: message.map(String::from), }; let mut bytes = Vec::with_capacity(128); diff --git a/crates/tranquil-pds/src/sync/verify.rs b/crates/tranquil-pds/src/sync/verify.rs index a5058c1..d5cf9aa 100644 --- a/crates/tranquil-pds/src/sync/verify.rs +++ b/crates/tranquil-pds/src/sync/verify.rs @@ -8,6 +8,7 @@ use reqwest::Client; use std::collections::HashMap; use thiserror::Error; use tracing::{debug, warn}; +use tranquil_types::Did; #[derive(Error, Debug)] pub enum VerifyError { @@ -57,7 +58,7 @@ impl CarVerifier { pub async fn verify_car( &self, - expected_did: &str, + expected_did: &Did, root_cid: &Cid, blocks: &HashMap, ) -> Result { @@ -67,22 +68,22 @@ impl CarVerifier { let commit = Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; let commit_did = commit.did().as_str(); - if commit_did != expected_did { + if commit_did != expected_did.as_str() { return Err(VerifyError::DidMismatch { commit_did: commit_did.to_string(), expected_did: expected_did.to_string(), }); } - let pubkey = self.resolve_did_signing_key(commit_did).await?; + let pubkey = self.resolve_did_signing_key(expected_did).await?; commit .verify(&pubkey) .map_err(|_| VerifyError::InvalidSignature)?; - debug!("Commit signature verified for DID {}", commit_did); + debug!("Commit signature verified for DID {}", expected_did); let data_cid = commit.data(); self.verify_mst_structure(data_cid, blocks)?; - debug!("MST structure verified for DID {}", commit_did); + debug!("MST structure verified for DID {}", expected_did); Ok(VerifiedCar { - did: commit_did.to_string(), + did: expected_did.clone(), rev: commit.rev().to_string(), data_cid: *data_cid, prev: commit.prev().cloned(), @@ -91,7 +92,7 @@ impl CarVerifier { pub fn verify_car_structure_only( &self, - expected_did: &str, + expected_did: &Did, root_cid: &Cid, blocks: &HashMap, ) -> Result { @@ -101,7 +102,7 @@ impl CarVerifier { let commit = Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?; let commit_did = commit.did().as_str(); - if commit_did != expected_did { + if commit_did != expected_did.as_str() { return Err(VerifyError::DidMismatch { commit_did: commit_did.to_string(), expected_did: expected_did.to_string(), @@ -111,17 +112,17 @@ impl CarVerifier { self.verify_mst_structure(data_cid, blocks)?; debug!( "MST structure verified for DID {} (signature verification skipped for migration)", - commit_did + expected_did ); Ok(VerifiedCar { - did: commit_did.to_string(), + did: expected_did.clone(), rev: commit.rev().to_string(), data_cid: *data_cid, prev: commit.prev().cloned(), }) } - async fn resolve_did_signing_key(&self, did: &str) -> Result, VerifyError> { + async fn resolve_did_signing_key(&self, did: &Did) -> Result, VerifyError> { let did_doc = self.resolve_did_document(did).await?; did_doc .atproto_public_key() @@ -129,15 +130,16 @@ impl CarVerifier { .ok_or(VerifyError::NoSigningKey) } - async fn resolve_did_document(&self, did: &str) -> Result, VerifyError> { - if did.starts_with("did:plc:") { - self.resolve_plc_did(did).await - } else if did.starts_with("did:web:") { - self.resolve_web_did(did).await + async fn resolve_did_document(&self, did: &Did) -> Result, VerifyError> { + let did_str = did.as_str(); + if did_str.starts_with("did:plc:") { + self.resolve_plc_did(did_str).await + } else if did_str.starts_with("did:web:") { + self.resolve_web_did(did_str).await } else { Err(VerifyError::DidResolutionFailed(format!( "Unsupported DID method: {}", - did + did_str ))) } } @@ -239,7 +241,7 @@ impl CarVerifier { let prefix_len = entry_obj .get("p") .and_then(|p| match p { - Ipld::Integer(i) => Some(*i as usize), + Ipld::Integer(i) => usize::try_from(*i).ok(), _ => None, }) .unwrap_or(0); @@ -294,7 +296,7 @@ impl CarVerifier { #[derive(Debug, Clone)] pub struct VerifiedCar { - pub did: String, + pub did: Did, pub rev: String, pub data_cid: Cid, pub prev: Option, diff --git a/crates/tranquil-pds/src/sync/verify_tests.rs b/crates/tranquil-pds/src/sync/verify_tests.rs index 6f4345b..80732a6 100644 --- a/crates/tranquil-pds/src/sync/verify_tests.rs +++ b/crates/tranquil-pds/src/sync/verify_tests.rs @@ -225,7 +225,8 @@ fn test_mst_validation_cycle_detection() { #[tokio::test] async fn test_unsupported_did_method() { let verifier = CarVerifier::new(); - let result = verifier.resolve_did_document("did:unknown:test").await; + let did: tranquil_types::Did = "did:unknown:test".parse().expect("valid DID format"); + let result = verifier.resolve_did_document(&did).await; assert!(result.is_err()); let err = result.unwrap_err(); assert!(matches!(err, VerifyError::DidResolutionFailed(_))); diff --git a/crates/tranquil-pds/src/types.rs b/crates/tranquil-pds/src/types.rs index 41fa026..c4a28e0 100644 --- a/crates/tranquil-pds/src/types.rs +++ b/crates/tranquil-pds/src/types.rs @@ -1 +1,7 @@ pub use tranquil_types::*; + +use std::sync::LazyLock; + +pub static PROFILE_COLLECTION: LazyLock = + LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap()); +pub static PROFILE_RKEY: LazyLock = LazyLock::new(|| "self".parse().unwrap()); diff --git a/crates/tranquil-pds/src/util.rs b/crates/tranquil-pds/src/util.rs index 1ee401a..a104e40 100644 --- a/crates/tranquil-pds/src/util.rs +++ b/crates/tranquil-pds/src/util.rs @@ -1,4 +1,4 @@ -use axum::http::HeaderMap; +use axum::http::{HeaderMap, HeaderName}; use cid::Cid; use ipld_core::ipld::Ipld; use rand::Rng; @@ -76,7 +76,20 @@ pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec .unwrap_or_default() } -pub fn get_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { +pub const HEADER_DPOP: HeaderName = HeaderName::from_static("dpop"); +pub const HEADER_DPOP_NONCE: HeaderName = HeaderName::from_static("dpop-nonce"); +pub const HEADER_ATPROTO_PROXY: HeaderName = HeaderName::from_static("atproto-proxy"); +pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName = + HeaderName::from_static("atproto-accept-labelers"); +pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev"); +pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName = + HeaderName::from_static("atproto-content-labelers"); +pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics"); + +pub fn get_header_str( + headers: &HeaderMap, + name: impl axum::http::header::AsHeaderName, +) -> Option<&str> { headers.get(name).and_then(|h| h.to_str().ok()) } @@ -140,6 +153,12 @@ pub fn telegram_bot_username() -> Option<&'static str> { TELEGRAM_BOT_USERNAME.get().map(|s| s.as_str()) } +pub fn parse_env_bool(key: &str) -> bool { + std::env::var(key) + .map(|v| v == "true" || v == "1") + .unwrap_or(false) +} + pub fn pds_public_url() -> String { format!("https://{}", pds_hostname()) } @@ -163,7 +182,7 @@ pub fn json_to_ipld(value: &JsonValue) -> Ipld { JsonValue::Bool(b) => Ipld::Bool(*b), JsonValue::Number(n) => { if let Some(i) = n.as_i64() { - Ipld::Integer(i as i128) + Ipld::Integer(i128::from(i)) } else if let Some(f) = n.as_f64() { Ipld::Float(f) } else { @@ -354,6 +373,30 @@ mod tests { panic!("Failed to find CID link in parsed CBOR"); } + #[test] + fn test_parse_env_bool_true_values() { + unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_1", "true") }; + assert!(parse_env_bool("TEST_PARSE_ENV_BOOL_1")); + unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_1", "1") }; + assert!(parse_env_bool("TEST_PARSE_ENV_BOOL_1")); + } + + #[test] + fn test_parse_env_bool_false_values() { + unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_2", "false") }; + assert!(!parse_env_bool("TEST_PARSE_ENV_BOOL_2")); + unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_2", "0") }; + assert!(!parse_env_bool("TEST_PARSE_ENV_BOOL_2")); + unsafe { std::env::set_var("TEST_PARSE_ENV_BOOL_2", "yes") }; + assert!(!parse_env_bool("TEST_PARSE_ENV_BOOL_2")); + } + + #[test] + fn test_parse_env_bool_unset() { + unsafe { std::env::remove_var("TEST_PARSE_ENV_BOOL_UNSET_KEY") }; + assert!(!parse_env_bool("TEST_PARSE_ENV_BOOL_UNSET_KEY")); + } + #[test] fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() { unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") }; diff --git a/crates/tranquil-pds/src/validation/mod.rs b/crates/tranquil-pds/src/validation/mod.rs index 586df12..ad46e6f 100644 --- a/crates/tranquil-pds/src/validation/mod.rs +++ b/crates/tranquil-pds/src/validation/mod.rs @@ -21,7 +21,8 @@ pub enum ValidationError { BannedContent { path: String }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] pub enum ValidationStatus { Valid, Unknown, diff --git a/crates/tranquil-pds/tests/account_notifications.rs b/crates/tranquil-pds/tests/account_notifications.rs index 52025eb..38fd6e3 100644 --- a/crates/tranquil-pds/tests/account_notifications.rs +++ b/crates/tranquil-pds/tests/account_notifications.rs @@ -112,11 +112,7 @@ async fn test_verify_channel_invalid_code() { .send() .await .unwrap(); - assert!( - resp.status() == 400 || resp.status() == 422, - "Expected 400 or 422, got {}", - resp.status() - ); + assert_eq!(resp.status(), 400); } #[tokio::test] @@ -137,11 +133,7 @@ async fn test_verify_channel_not_set() { .send() .await .unwrap(); - assert!( - resp.status() == 400 || resp.status() == 422, - "Expected 400 or 422, got {}", - resp.status() - ); + assert_eq!(resp.status(), 400); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/commit_signing.rs b/crates/tranquil-pds/tests/commit_signing.rs index 4ae8650..df8d237 100644 --- a/crates/tranquil-pds/tests/commit_signing.rs +++ b/crates/tranquil-pds/tests/commit_signing.rs @@ -99,7 +99,9 @@ fn test_create_signed_commit_helper() { use tranquil_pds::api::repo::record::utils::create_signed_commit; let signing_key = SigningKey::random(&mut rand::thread_rng()); - let did = unsafe { Did::new_unchecked("did:plc:testuser123456789abcdef") }; + let did: Did = "did:plc:testuser123456789abcdef" + .parse() + .expect("valid test DID"); let data_cid = Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); let rev = Tid::now(LimitedU32::MIN).to_string(); diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 79a350a..b9836a4 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -98,7 +98,10 @@ fn cleanup() { #[allow(dead_code)] pub fn client() -> Client { - Client::new() + Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("Failed to build HTTP client") } #[allow(dead_code)] diff --git a/crates/tranquil-pds/tests/delete_account.rs b/crates/tranquil-pds/tests/delete_account.rs index 29b0460..4c59565 100644 --- a/crates/tranquil-pds/tests/delete_account.rs +++ b/crates/tranquil-pds/tests/delete_account.rs @@ -365,7 +365,7 @@ async fn test_delete_account_missing_fields() { .send() .await .expect("Failed to send request"); - assert_eq!(res1.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(res1.status(), StatusCode::BAD_REQUEST); let res2 = client .post(format!( "{}/xrpc/com.atproto.server.deleteAccount", @@ -378,7 +378,7 @@ async fn test_delete_account_missing_fields() { .send() .await .expect("Failed to send request"); - assert_eq!(res2.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(res2.status(), StatusCode::BAD_REQUEST); let res3 = client .post(format!( "{}/xrpc/com.atproto.server.deleteAccount", @@ -391,7 +391,7 @@ async fn test_delete_account_missing_fields() { .send() .await .expect("Failed to send request"); - assert_eq!(res3.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(res3.status(), StatusCode::BAD_REQUEST); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/firehose/mod.rs b/crates/tranquil-pds/tests/firehose/mod.rs new file mode 100644 index 0000000..2ee0bb3 --- /dev/null +++ b/crates/tranquil-pds/tests/firehose/mod.rs @@ -0,0 +1,439 @@ +use cid::Cid; +use futures::stream::StreamExt; +use serde::Deserialize; +use std::sync::{Arc, Mutex}; +use tokio::task::JoinHandle; +use tokio_tungstenite::{connect_async, tungstenite}; +use tokio_util::sync::CancellationToken; +use tranquil_scopes::RepoAction; + +#[derive(Debug)] +pub enum FirehoseFrame { + Commit(Box), + Identity(IdentityData), + Account(AccountData), + Info(InfoData), + Error(ErrorData), + Unknown(Vec), +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct ParsedCommitFrame { + pub seq: i64, + pub repo: String, + pub commit: Cid, + pub rev: String, + pub since: Option, + pub blocks: Vec, + pub ops: Vec, + pub blobs: Vec, + pub time: String, + pub prev_data: Option, +} + +#[derive(Debug, Clone)] +pub struct ParsedRepoOp { + pub action: RepoAction, + pub path: String, + pub cid: Option, + pub prev: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct IdentityData { + pub did: String, + pub seq: i64, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct AccountData { + pub did: String, + pub seq: i64, + pub active: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct InfoData { + pub name: String, + pub message: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct ErrorData { + pub error: String, + pub message: Option, +} + +#[derive(Debug, Deserialize)] +struct RawFrameHeader { + op: i64, + #[serde(default)] + t: Option, +} + +#[derive(Debug, Deserialize)] +struct RawCommitBody { + seq: i64, + repo: String, + commit: Cid, + rev: String, + since: Option, + #[serde(with = "serde_bytes")] + blocks: Vec, + ops: Vec, + #[serde(default)] + blobs: Vec, + time: String, + #[serde(rename = "prevData")] + prev_data: Option, +} + +#[derive(Debug, Deserialize)] +struct RawOp { + action: RepoAction, + path: String, + cid: Option, + prev: Option, +} + +#[derive(Debug, Deserialize)] +struct RawIdentityBody { + did: String, + seq: i64, +} + +#[derive(Debug, Deserialize)] +struct RawAccountBody { + did: String, + seq: i64, + active: bool, +} + +#[derive(Debug, Deserialize)] +struct RawInfoBody { + name: String, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct RawErrorBody { + error: String, + message: Option, +} + +pub struct FirehoseConsumer { + frames: Arc>>, + cancel: CancellationToken, + handle: JoinHandle<()>, +} + +impl FirehoseConsumer { + pub async fn connect(port: u16) -> Self { + Self::connect_inner(port, None).await + } + + pub async fn connect_with_cursor(port: u16, cursor: i64) -> Self { + Self::connect_inner(port, Some(cursor)).await + } + + async fn connect_inner(port: u16, cursor: Option) -> Self { + let url = match cursor { + Some(c) => format!( + "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos?cursor={}", + port, c + ), + None => format!( + "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos", + port + ), + }; + let (ws_stream, _) = connect_async(&url) + .await + .expect("Failed to connect to firehose"); + let frames: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cancel = CancellationToken::new(); + + let frames_clone = frames.clone(); + let cancel_clone = cancel.clone(); + + let handle = tokio::spawn(async move { + let (_, mut read) = ws_stream.split(); + loop { + tokio::select! { + _ = cancel_clone.cancelled() => break, + msg = read.next() => { + match msg { + Some(Ok(tungstenite::Message::Binary(bin))) => { + let frame = parse_frame_bytes(&bin); + frames_clone.lock().unwrap().push(frame); + } + Some(Ok(tungstenite::Message::Close(_))) | None => break, + _ => {} + } + } + } + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + Self { + frames, + cancel, + handle, + } + } + + pub async fn wait_for_commits( + &self, + did: &str, + count: usize, + timeout: std::time::Duration, + ) -> Vec { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let matching: Vec = self + .frames + .lock() + .unwrap() + .iter() + .filter_map(|f| match f { + FirehoseFrame::Commit(c) if c.repo == did => Some(ParsedCommitFrame::clone(c)), + _ => None, + }) + .collect(); + if matching.len() >= count { + return matching; + } + if tokio::time::Instant::now() >= deadline { + panic!( + "Timed out waiting for {} commits for DID {}, got {}", + count, + did, + matching.len() + ); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + + #[allow(dead_code)] + pub fn all_frames(&self) -> Vec { + self.frames.lock().unwrap().drain(..).collect() + } + + pub fn all_commits(&self) -> Vec { + self.frames + .lock() + .unwrap() + .iter() + .filter_map(|f| match f { + FirehoseFrame::Commit(c) => Some(ParsedCommitFrame::clone(c)), + _ => None, + }) + .collect() + } +} + +impl Drop for FirehoseConsumer { + fn drop(&mut self) { + self.cancel.cancel(); + self.handle.abort(); + } +} + +impl Clone for FirehoseFrame { + fn clone(&self) -> Self { + match self { + Self::Commit(c) => Self::Commit(c.clone()), + Self::Identity(i) => Self::Identity(i.clone()), + Self::Account(a) => Self::Account(a.clone()), + Self::Info(i) => Self::Info(i.clone()), + Self::Error(e) => Self::Error(e.clone()), + Self::Unknown(b) => Self::Unknown(b.clone()), + } + } +} + +fn find_cbor_map_end(bytes: &[u8]) -> Result { + let mut pos = 0; + + fn read_uint(bytes: &[u8], pos: &mut usize, additional: u8) -> Result { + match additional { + 0..=23 => Ok(additional as u64), + 24 => { + if *pos >= bytes.len() { + return Err("Unexpected end".into()); + } + let val = bytes[*pos] as u64; + *pos += 1; + Ok(val) + } + 25 => { + if *pos + 2 > bytes.len() { + return Err("Unexpected end".into()); + } + let val = u16::from_be_bytes([bytes[*pos], bytes[*pos + 1]]) as u64; + *pos += 2; + Ok(val) + } + 26 => { + if *pos + 4 > bytes.len() { + return Err("Unexpected end".into()); + } + let val = u32::from_be_bytes([ + bytes[*pos], + bytes[*pos + 1], + bytes[*pos + 2], + bytes[*pos + 3], + ]) as u64; + *pos += 4; + Ok(val) + } + 27 => { + if *pos + 8 > bytes.len() { + return Err("Unexpected end".into()); + } + let val = u64::from_be_bytes([ + bytes[*pos], + bytes[*pos + 1], + bytes[*pos + 2], + bytes[*pos + 3], + bytes[*pos + 4], + bytes[*pos + 5], + bytes[*pos + 6], + bytes[*pos + 7], + ]); + *pos += 8; + Ok(val) + } + _ => Err(format!("Invalid additional info: {}", additional)), + } + } + + fn skip_value(bytes: &[u8], pos: &mut usize) -> Result<(), String> { + if *pos >= bytes.len() { + return Err("Unexpected end".into()); + } + let initial = bytes[*pos]; + *pos += 1; + let major = initial >> 5; + let additional = initial & 0x1f; + + match major { + 0 | 1 => { + read_uint(bytes, pos, additional)?; + Ok(()) + } + 2 | 3 => { + let len = read_uint(bytes, pos, additional)? as usize; + *pos += len; + Ok(()) + } + 4 => { + let len = read_uint(bytes, pos, additional)?; + (0..len).try_for_each(|_| skip_value(bytes, pos)) + } + 5 => { + let len = read_uint(bytes, pos, additional)?; + (0..len).try_for_each(|_| { + skip_value(bytes, pos)?; + skip_value(bytes, pos) + }) + } + 6 => { + read_uint(bytes, pos, additional)?; + skip_value(bytes, pos) + } + 7 => Ok(()), + _ => Err(format!("Unknown major type: {}", major)), + } + } + + skip_value(bytes, &mut pos)?; + Ok(pos) +} + +pub fn parse_frame_bytes(raw: &[u8]) -> FirehoseFrame { + let header_end = match find_cbor_map_end(raw) { + Ok(e) => e, + Err(_) => return FirehoseFrame::Unknown(raw.to_vec()), + }; + + let header: RawFrameHeader = match serde_ipld_dagcbor::from_slice(&raw[..header_end]) { + Ok(h) => h, + Err(_) => return FirehoseFrame::Unknown(raw.to_vec()), + }; + + let body = &raw[header_end..]; + + if header.op == -1 { + return serde_ipld_dagcbor::from_slice::(body) + .map(|b| { + FirehoseFrame::Error(ErrorData { + error: b.error, + message: b.message, + }) + }) + .unwrap_or_else(|_| FirehoseFrame::Unknown(raw.to_vec())); + } + + match header.t.as_deref() { + Some("#commit") => serde_ipld_dagcbor::from_slice::(body) + .map(|b| { + FirehoseFrame::Commit(Box::new(ParsedCommitFrame { + seq: b.seq, + repo: b.repo, + commit: b.commit, + rev: b.rev, + since: b.since, + blocks: b.blocks, + ops: b + .ops + .into_iter() + .map(|op| ParsedRepoOp { + action: op.action, + path: op.path, + cid: op.cid, + prev: op.prev, + }) + .collect(), + blobs: b.blobs, + time: b.time, + prev_data: b.prev_data, + })) + }) + .unwrap_or_else(|_| FirehoseFrame::Unknown(raw.to_vec())), + Some("#identity") => serde_ipld_dagcbor::from_slice::(body) + .map(|b| { + FirehoseFrame::Identity(IdentityData { + did: b.did, + seq: b.seq, + }) + }) + .unwrap_or_else(|_| FirehoseFrame::Unknown(raw.to_vec())), + Some("#account") => serde_ipld_dagcbor::from_slice::(body) + .map(|b| { + FirehoseFrame::Account(AccountData { + did: b.did, + seq: b.seq, + active: b.active, + }) + }) + .unwrap_or_else(|_| FirehoseFrame::Unknown(raw.to_vec())), + Some("#info") => serde_ipld_dagcbor::from_slice::(body) + .map(|b| { + FirehoseFrame::Info(InfoData { + name: b.name, + message: b.message, + }) + }) + .unwrap_or_else(|_| FirehoseFrame::Unknown(raw.to_vec())), + _ => FirehoseFrame::Unknown(raw.to_vec()), + } +} diff --git a/crates/tranquil-pds/tests/identity.rs b/crates/tranquil-pds/tests/identity.rs index 333d45d..6e22eb3 100644 --- a/crates/tranquil-pds/tests/identity.rs +++ b/crates/tranquil-pds/tests/identity.rs @@ -547,7 +547,7 @@ async fn test_verify_handle_ownership_invalid_did() { .send() .await .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); } #[tokio::test] @@ -582,7 +582,7 @@ async fn test_verify_handle_ownership_missing_fields() { .send() .await .expect("Failed to send request"); - assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); } #[tokio::test] diff --git a/crates/tranquil-pds/tests/jwt_security.rs b/crates/tranquil-pds/tests/jwt_security.rs index 7a97348..a469607 100644 --- a/crates/tranquil-pds/tests/jwt_security.rs +++ b/crates/tranquil-pds/tests/jwt_security.rs @@ -10,10 +10,9 @@ use reqwest::StatusCode; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tranquil_pds::auth::{ - self, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, - TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, create_access_token, - create_refresh_token, create_service_token, get_did_from_token, get_jti_from_token, - verify_access_token, verify_refresh_token, verify_token, + self, TokenScope, TokenType, create_access_token, create_refresh_token, create_service_token, + get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, + verify_token, }; fn generate_user_key() -> Vec { @@ -100,11 +99,11 @@ fn test_algorithm_substitution_attacks() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let none_header = json!({ "alg": "none", "typ": TOKEN_TYPE_ACCESS }); + let none_header = json!({ "alg": "none", "typ": TokenType::Access.as_str() }); let claims = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, - "jti": "attack-token", "scope": SCOPE_ACCESS + "jti": "attack-token", "scope": TokenScope::Access.as_str() }); let none_token = create_unsigned_jwt(&none_header, &claims); assert!( @@ -112,7 +111,7 @@ fn test_algorithm_substitution_attacks() { "Algorithm 'none' must be rejected" ); - let hs256_header = json!({ "alg": "HS256", "typ": TOKEN_TYPE_ACCESS }); + let hs256_header = json!({ "alg": "HS256", "typ": TokenType::Access.as_str() }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&hs256_header).unwrap()); let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap()); use hmac::{Hmac, Mac}; @@ -128,7 +127,7 @@ fn test_algorithm_substitution_attacks() { ); for (alg, sig_len) in [("RS256", 256), ("ES256", 64)] { - let header = json!({ "alg": alg, "typ": TOKEN_TYPE_ACCESS }); + let header = json!({ "alg": alg, "typ": TokenType::Access.as_str() }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); let fake_sig = URL_SAFE_NO_PAD.encode(vec![1u8; sig_len]); let token = format!("{}.{}.{}", header_b64, claims_b64, fake_sig); @@ -179,7 +178,7 @@ fn test_token_type_confusion() { fn test_scope_validation() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let header = json!({ "alg": "ES256K", "typ": TOKEN_TYPE_ACCESS }); + let header = json!({ "alg": "ES256K", "typ": TokenType::Access.as_str() }); let invalid_scope = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", @@ -225,7 +224,11 @@ fn test_scope_validation() { .is_err() ); - for scope in [SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED] { + for scope in [ + TokenScope::Access.as_str(), + TokenScope::AppPass.as_str(), + TokenScope::AppPassPrivileged.as_str(), + ] { let claims = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, @@ -240,7 +243,7 @@ fn test_scope_validation() { let refresh_scope = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, - "jti": "test", "scope": SCOPE_REFRESH + "jti": "test", "scope": TokenScope::Refresh.as_str() }); assert!( verify_access_token( @@ -255,12 +258,12 @@ fn test_scope_validation() { fn test_expiration_and_timing() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let header = json!({ "alg": "ES256K", "typ": TOKEN_TYPE_ACCESS }); + let header = json!({ "alg": "ES256K", "typ": TokenType::Access.as_str() }); let now = Utc::now().timestamp(); let expired = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", - "iat": now - 7200, "exp": now - 3600, "jti": "test", "scope": SCOPE_ACCESS + "iat": now - 7200, "exp": now - 3600, "jti": "test", "scope": TokenScope::Access.as_str() }); let result = verify_access_token( &create_custom_jwt(&header, &expired, &key_bytes), @@ -270,7 +273,7 @@ fn test_expiration_and_timing() { let future_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", - "iat": now + 60, "exp": now + 7200, "jti": "test", "scope": SCOPE_ACCESS + "iat": now + 60, "exp": now + 7200, "jti": "test", "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -282,7 +285,7 @@ fn test_expiration_and_timing() { let just_expired = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", - "iat": now - 10, "exp": now - 1, "jti": "test", "scope": SCOPE_ACCESS + "iat": now - 10, "exp": now - 1, "jti": "test", "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -294,7 +297,7 @@ fn test_expiration_and_timing() { let far_future = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", - "iat": now, "exp": i64::MAX, "jti": "test", "scope": SCOPE_ACCESS + "iat": now, "exp": i64::MAX, "jti": "test", "scope": TokenScope::Access.as_str() }); let _ = verify_access_token( &create_custom_jwt(&header, &far_future, &key_bytes), @@ -303,7 +306,7 @@ fn test_expiration_and_timing() { let negative_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", - "iat": -1000000000i64, "exp": now + 3600, "jti": "test", "scope": SCOPE_ACCESS + "iat": -1000000000i64, "exp": now + 3600, "jti": "test", "scope": TokenScope::Access.as_str() }); let _ = verify_access_token( &create_custom_jwt(&header, &negative_iat, &key_bytes), @@ -359,11 +362,11 @@ fn test_malformed_tokens() { fn test_claim_validation() { let key_bytes = generate_user_key(); let did = "did:plc:test"; - let header = json!({ "alg": "ES256K", "typ": TOKEN_TYPE_ACCESS }); + let header = json!({ "alg": "ES256K", "typ": TokenType::Access.as_str() }); let missing_exp = json!({ "iss": did, "sub": did, "aud": "did:web:test", - "iat": Utc::now().timestamp(), "scope": SCOPE_ACCESS + "iat": Utc::now().timestamp(), "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -375,7 +378,7 @@ fn test_claim_validation() { let missing_iat = json!({ "iss": did, "sub": did, "aud": "did:web:test", - "exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS + "exp": Utc::now().timestamp() + 3600, "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -387,7 +390,7 @@ fn test_claim_validation() { let missing_sub = json!({ "iss": did, "aud": "did:web:test", - "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "scope": SCOPE_ACCESS + "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -399,7 +402,7 @@ fn test_claim_validation() { let wrong_types = json!({ "iss": 12345, "sub": ["did:plc:test"], "aud": {"url": "did:web:test"}, - "iat": "not a number", "exp": "also not a number", "jti": null, "scope": SCOPE_ACCESS + "iat": "not a number", "exp": "also not a number", "jti": null, "scope": TokenScope::Access.as_str() }); assert!( verify_access_token( @@ -412,7 +415,7 @@ fn test_claim_validation() { let unicode_injection = json!({ "iss": "did:plc:test\u{0000}attacker", "sub": "did:plc:test\u{202E}rekatta", "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, - "jti": "test", "scope": SCOPE_ACCESS + "jti": "test", "scope": TokenScope::Access.as_str() }); if let Ok(data) = verify_access_token( &create_custom_jwt(&header, &unicode_injection, &key_bytes), @@ -453,13 +456,13 @@ fn test_header_injection_and_constant_time() { let did = "did:plc:test"; let header = json!({ - "alg": "ES256K", "typ": TOKEN_TYPE_ACCESS, + "alg": "ES256K", "typ": TokenType::Access.as_str(), "kid": "../../../../../../etc/passwd", "jku": "https://attacker.com/keys" }); let claims = json!({ "iss": did, "sub": did, "aud": "did:web:test.pds", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, - "jti": "test", "scope": SCOPE_ACCESS + "jti": "test", "scope": TokenScope::Access.as_str() }); assert!( verify_access_token(&create_custom_jwt(&header, &claims, &key_bytes), &key_bytes).is_ok() diff --git a/crates/tranquil-pds/tests/plc_validation.rs b/crates/tranquil-pds/tests/plc_validation.rs index ee37640..61b5024 100644 --- a/crates/tranquil-pds/tests/plc_validation.rs +++ b/crates/tranquil-pds/tests/plc_validation.rs @@ -2,9 +2,9 @@ use k256::ecdsa::SigningKey; use serde_json::json; use std::collections::HashMap; use tranquil_pds::plc::{ - PlcError, PlcOperation, PlcService, PlcValidationContext, cid_for_cbor, sign_operation, - signing_key_to_did_key, validate_plc_operation, validate_plc_operation_for_submission, - verify_operation_signature, + PlcError, PlcOpType, PlcOperation, PlcService, PlcValidationContext, cid_for_cbor, + sign_operation, signing_key_to_did_key, validate_plc_operation, + validate_plc_operation_for_submission, verify_operation_signature, }; fn create_valid_operation() -> serde_json::Value { @@ -264,14 +264,14 @@ fn test_sign_operation_and_struct() { services.insert( "atproto_pds".to_string(), PlcService { - service_type: "AtprotoPersonalDataServer".to_string(), + service_type: tranquil_pds::plc::ServiceType::Pds, endpoint: "https://pds.example.com".to_string(), }, ); let mut verification_methods = HashMap::new(); verification_methods.insert("atproto".to_string(), "did:key:zTest123".to_string()); let op = PlcOperation { - op_type: "plc_operation".to_string(), + op_type: PlcOpType::Operation, rotation_keys: vec!["did:key:zTest123".to_string()], verification_methods, also_known_as: vec!["at://test.handle".to_string()], diff --git a/crates/tranquil-pds/tests/repo_lifecycle.rs b/crates/tranquil-pds/tests/repo_lifecycle.rs new file mode 100644 index 0000000..e30ca7a --- /dev/null +++ b/crates/tranquil-pds/tests/repo_lifecycle.rs @@ -0,0 +1,581 @@ +mod common; +mod firehose; + +use cid::Cid; +use common::*; +use firehose::{FirehoseConsumer, ParsedCommitFrame}; +use iroh_car::CarReader; +use jacquard_repo::commit::Commit; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use std::io::Cursor; +use std::str::FromStr; +use tranquil_scopes::RepoAction; + +mod helpers; + +async fn create_post_record(client: &reqwest::Client, token: &str, did: &str, text: &str) -> Value { + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": text, + "createdAt": chrono::Utc::now().to_rfc3339(), + } + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + .expect("Failed to create post"); + assert_eq!(res.status(), StatusCode::OK); + res.json().await.expect("Invalid JSON from createRecord") +} + +async fn get_latest_commit(client: &reqwest::Client, token: &str, did: &str) -> Value { + let res = client + .get(format!( + "{}/xrpc/com.atproto.sync.getLatestCommit?did={}", + base_url().await, + did + )) + .bearer_auth(token) + .send() + .await + .expect("Failed to get latest commit"); + assert_eq!(res.status(), StatusCode::OK); + res.json().await.expect("Invalid JSON from getLatestCommit") +} + +#[tokio::test] +async fn test_create_record_cid_matches_firehose() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let api_response = create_post_record(&client, &token, &did, "CID match test").await; + let api_commit_cid = api_response["commit"]["cid"].as_str().unwrap(); + let api_commit_rev = api_response["commit"]["rev"].as_str().unwrap(); + let api_record_cid = api_response["cid"].as_str().unwrap(); + + let frames = consumer + .wait_for_commits(&did, 1, std::time::Duration::from_secs(10)) + .await; + let frame = &frames[0]; + + assert_eq!( + api_commit_cid, + frame.commit.to_string(), + "API commit CID must match firehose commit CID" + ); + assert_eq!( + api_commit_rev, frame.rev, + "API commit rev must match firehose rev" + ); + assert_eq!(frame.ops.len(), 1, "Expected exactly 1 op"); + assert_eq!( + api_record_cid, + frame.ops[0].cid.unwrap().to_string(), + "API record CID must match firehose op CID" + ); + assert_eq!(frame.ops[0].action, RepoAction::Create); + assert!(frame.ops[0].prev.is_none(), "Create op must have no prev"); + + let latest = get_latest_commit(&client, &token, &did).await; + assert_eq!( + latest["cid"].as_str().unwrap(), + api_commit_cid, + "getLatestCommit CID must match" + ); + assert_eq!( + latest["rev"].as_str().unwrap(), + api_commit_rev, + "getLatestCommit rev must match" + ); +} + +#[tokio::test] +async fn test_update_record_prev_matches_old_cid() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let v1_payload = json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Profile v1", + } + }); + let v1_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&v1_payload) + .send() + .await + .expect("Failed to create profile v1"); + assert_eq!(v1_res.status(), StatusCode::OK); + let v1_body: Value = v1_res.json().await.unwrap(); + let v1_cid_str = v1_body["cid"].as_str().unwrap(); + let v1_cid = Cid::from_str(v1_cid_str).unwrap(); + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let v2_payload = json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { + "$type": "app.bsky.actor.profile", + "displayName": "Profile v2", + } + }); + let v2_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&v2_payload) + .send() + .await + .expect("Failed to update profile v2"); + assert_eq!(v2_res.status(), StatusCode::OK); + let v2_body: Value = v2_res.json().await.unwrap(); + let v2_cid_str = v2_body["cid"].as_str().unwrap(); + let v2_cid = Cid::from_str(v2_cid_str).unwrap(); + + let frames = consumer + .wait_for_commits(&did, 1, std::time::Duration::from_secs(10)) + .await; + let frame = &frames[0]; + + let profile_op = frame + .ops + .iter() + .find(|op| op.path.contains("app.bsky.actor.profile")) + .expect("No profile op found"); + + assert_eq!(profile_op.action, RepoAction::Update); + assert_eq!( + profile_op.prev, + Some(v1_cid), + "Update op.prev must be the old CID" + ); + assert_eq!( + profile_op.cid, + Some(v2_cid), + "Update op.cid must be the new CID" + ); + assert!( + frame.prev_data.is_some(), + "Update commit must have prevData" + ); +} + +#[tokio::test] +async fn test_delete_record_prev_set_cid_none() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let create_body = create_post_record(&client, &token, &did, "To be deleted").await; + let record_cid = Cid::from_str(create_body["cid"].as_str().unwrap()).unwrap(); + let uri = create_body["uri"].as_str().unwrap(); + let parts: Vec<&str> = uri.split('/').collect(); + let collection = parts[parts.len() - 2]; + let rkey = parts[parts.len() - 1]; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let delete_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + }); + let del_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.deleteRecord", + base_url().await + )) + .bearer_auth(&token) + .json(&delete_payload) + .send() + .await + .expect("Failed to delete record"); + assert_eq!(del_res.status(), StatusCode::OK); + + let frames = consumer + .wait_for_commits(&did, 1, std::time::Duration::from_secs(10)) + .await; + let frame = &frames[0]; + + assert_eq!(frame.ops.len(), 1, "Expected exactly 1 delete op"); + let op = &frame.ops[0]; + assert_eq!(op.action, RepoAction::Delete); + assert!(op.cid.is_none(), "Delete op.cid must be None"); + assert_eq!( + op.prev, + Some(record_cid), + "Delete op.prev must be the original CID" + ); +} + +#[tokio::test] +async fn test_five_record_commit_chain_integrity() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let texts = [ + "Chain post 0", + "Chain post 1", + "Chain post 2", + "Chain post 3", + "Chain post 4", + ]; + for text in &texts { + create_post_record(&client, &token, &did, text).await; + } + + let mut frames = consumer + .wait_for_commits(&did, 5, std::time::Duration::from_secs(15)) + .await; + frames.sort_by_key(|f| f.seq); + + let revs: Vec<&str> = frames.iter().map(|f| f.rev.as_str()).collect(); + let unique_revs: std::collections::HashSet<&&str> = revs.iter().collect(); + assert_eq!( + unique_revs.len(), + 5, + "All rev values must be distinct, got: {:?}", + revs + ); + + let seqs: Vec = frames.iter().map(|f| f.seq).collect(); + seqs.windows(2).for_each(|pair| { + assert!( + pair[1] > pair[0], + "Seq values must be strictly monotonically increasing: {} <= {}", + pair[1], + pair[0] + ); + }); + + frames.iter().enumerate().skip(1).for_each(|(i, frame)| { + assert_eq!( + frame.since.as_deref(), + Some(frames[i - 1].rev.as_str()), + "Frame {} since must equal frame {} rev", + i, + i - 1 + ); + }); + + let latest = get_latest_commit(&client, &token, &did).await; + let final_frame = frames.last().unwrap(); + assert_eq!( + latest["cid"].as_str().unwrap(), + final_frame.commit.to_string(), + "getLatestCommit CID must match final frame" + ); + assert_eq!( + latest["rev"].as_str().unwrap(), + final_frame.rev, + "getLatestCommit rev must match final frame" + ); +} + +#[tokio::test] +async fn test_apply_writes_single_commit_multiple_ops() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let now = chrono::Utc::now().to_rfc3339(); + let writes: Vec = (0..3) + .map(|i| { + json!({ + "$type": "com.atproto.repo.applyWrites#create", + "collection": "app.bsky.feed.post", + "value": { + "$type": "app.bsky.feed.post", + "text": format!("Batch post {}", i), + "createdAt": now, + } + }) + }) + .collect(); + + let payload = json!({ + "repo": did, + "writes": writes, + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&token) + .json(&payload) + .send() + .await + .expect("Failed to applyWrites"); + assert_eq!(res.status(), StatusCode::OK); + let api_body: Value = res.json().await.unwrap(); + let api_results = api_body["results"].as_array().expect("No results array"); + assert_eq!(api_results.len(), 3, "Expected 3 results from applyWrites"); + + let frames = consumer + .wait_for_commits(&did, 1, std::time::Duration::from_secs(10)) + .await; + assert_eq!( + frames.len(), + 1, + "applyWrites should produce exactly 1 commit" + ); + let frame = &frames[0]; + assert_eq!(frame.ops.len(), 3, "Commit should contain 3 ops"); + + frame.ops.iter().for_each(|op| { + assert_eq!(op.action, RepoAction::Create, "All ops should be Create"); + }); + + api_results.iter().enumerate().for_each(|(i, result)| { + let api_cid = result["cid"].as_str().expect("No cid in result"); + let frame_cid = frame.ops[i].cid.expect("No cid in op").to_string(); + assert_eq!( + api_cid, frame_cid, + "API result[{}] CID must match firehose op[{}] CID", + i, i + ); + }); +} + +#[tokio::test] +async fn test_firehose_commit_signature_verification() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let key_bytes = helpers::get_user_signing_key(&did) + .await + .expect("Failed to get signing key"); + let signing_key = + k256::ecdsa::SigningKey::from_slice(&key_bytes).expect("Invalid signing key bytes"); + let pubkey_bytes = signing_key.verifying_key().to_encoded_point(true); + let pubkey = jacquard_common::types::crypto::PublicKey { + codec: jacquard_common::types::crypto::KeyCodec::Secp256k1, + bytes: std::borrow::Cow::Owned(pubkey_bytes.as_bytes().to_vec()), + }; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let _api_response = + create_post_record(&client, &token, &did, "Signature verification test").await; + + let frames = consumer + .wait_for_commits(&did, 1, std::time::Duration::from_secs(10)) + .await; + let frame = &frames[0]; + + let mut car_reader = CarReader::new(Cursor::new(&frame.blocks)) + .await + .expect("Failed to parse CAR"); + let mut blocks = std::collections::HashMap::new(); + while let Ok(Some((cid, data))) = car_reader.next_block().await { + blocks.insert(cid, data); + } + + let commit_block = blocks + .get(&frame.commit) + .expect("Commit block not found in CAR"); + + let commit = Commit::from_cbor(commit_block).expect("Failed to parse commit from CBOR"); + + commit + .verify(&pubkey) + .expect("Commit signature verification failed"); + + assert_eq!( + commit.rev().to_string(), + frame.rev, + "Commit rev must match frame rev" + ); + assert_eq!( + commit.did().as_str(), + did, + "Commit DID must match account DID" + ); +} + +#[tokio::test] +async fn test_cursor_backfill_completeness() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let pool = get_test_db_pool().await; + let baseline_seq: i64 = + sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + + let mut expected_cids: Vec = Vec::with_capacity(5); + let texts = [ + "Backfill 0", + "Backfill 1", + "Backfill 2", + "Backfill 3", + "Backfill 4", + ]; + for text in &texts { + let body = create_post_record(&client, &token, &did, text).await; + expected_cids.push(body["commit"]["cid"].as_str().unwrap().to_string()); + } + + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), baseline_seq).await; + + let frames = consumer + .wait_for_commits(&did, 5, std::time::Duration::from_secs(15)) + .await; + + let mut sorted_frames: Vec = frames; + sorted_frames.sort_by_key(|f| f.seq); + + let received_cids: Vec = sorted_frames.iter().map(|f| f.commit.to_string()).collect(); + + expected_cids.iter().for_each(|expected| { + assert!( + received_cids.contains(expected), + "Missing commit {} in backfill", + expected + ); + }); + + let seqs: Vec = sorted_frames.iter().map(|f| f.seq).collect(); + let unique_seqs: std::collections::HashSet<&i64> = seqs.iter().collect(); + assert_eq!( + unique_seqs.len(), + seqs.len(), + "No duplicate seq values allowed in backfill" + ); +} + +#[tokio::test] +async fn test_multi_account_seq_interleaving() { + let client = client(); + let (alice_token, alice_did) = create_account_and_login(&client).await; + let (bob_token, bob_did) = create_account_and_login(&client).await; + + let pool = get_test_db_pool().await; + let cursor: i64 = sqlx::query_scalar::<_, i64>("SELECT COALESCE(MAX(seq), 0) FROM repo_seq") + .fetch_one(pool) + .await + .unwrap(); + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), cursor).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let _a1 = create_post_record(&client, &alice_token, &alice_did, "Alice post 1").await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _b1 = create_post_record(&client, &bob_token, &bob_did, "Bob post 1").await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _a2 = create_post_record(&client, &alice_token, &alice_did, "Alice post 2").await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let _b2 = create_post_record(&client, &bob_token, &bob_did, "Bob post 2").await; + + let alice_frames = consumer + .wait_for_commits(&alice_did, 2, std::time::Duration::from_secs(10)) + .await; + let bob_frames = consumer + .wait_for_commits(&bob_did, 2, std::time::Duration::from_secs(10)) + .await; + + let mut all_commits = consumer.all_commits(); + all_commits.sort_by_key(|f| f.seq); + + let global_seqs: Vec = all_commits.iter().map(|f| f.seq).collect(); + global_seqs.windows(2).for_each(|pair| { + assert!( + pair[1] > pair[0], + "Global seq must be strictly monotonically increasing: {} <= {}", + pair[1], + pair[0] + ); + }); + + let mut alice_sorted: Vec = alice_frames; + alice_sorted.sort_by_key(|f| f.seq); + assert_eq!(alice_sorted.len(), 2); + assert!( + alice_sorted[1].since.is_some(), + "Alice's second commit must have since" + ); + assert_eq!( + alice_sorted[1].since.as_deref(), + Some(alice_sorted[0].rev.as_str()), + "Alice's since chain must be self-consistent" + ); + + let mut bob_sorted: Vec = bob_frames; + bob_sorted.sort_by_key(|f| f.seq); + assert_eq!(bob_sorted.len(), 2); + assert!( + bob_sorted[1].since.is_some(), + "Bob's second commit must have since" + ); + assert_eq!( + bob_sorted[1].since.as_deref(), + Some(bob_sorted[0].rev.as_str()), + "Bob's since chain must be self-consistent" + ); +} diff --git a/crates/tranquil-pds/tests/ripple_cluster.rs b/crates/tranquil-pds/tests/ripple_cluster.rs index c8e917f..88d97ac 100644 --- a/crates/tranquil-pds/tests/ripple_cluster.rs +++ b/crates/tranquil-pds/tests/ripple_cluster.rs @@ -677,6 +677,19 @@ async fn cross_node_rate_limit_via_login() { let nodes = common::cluster().await; let client = common::client(); + let now_ms = u64::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(), + ) + .unwrap_or(u64::MAX); + let login_window_ms: u64 = 60_000; + let remaining = login_window_ms - (now_ms % login_window_ms); + if remaining < 35_000 { + tokio::time::sleep(Duration::from_millis(remaining + 100)).await; + } + let uuid_bytes = uuid::Uuid::new_v4(); let b = uuid_bytes.as_bytes(); let unique_ip = format!("10.{}.{}.{}", b[0], b[1], b[2]); diff --git a/crates/tranquil-pds/tests/server.rs b/crates/tranquil-pds/tests/server.rs index 32329fb..d380747 100644 --- a/crates/tranquil-pds/tests/server.rs +++ b/crates/tranquil-pds/tests/server.rs @@ -65,10 +65,7 @@ async fn test_account_and_session_lifecycle() { .send() .await .unwrap(); - assert!( - missing_id.status() == StatusCode::BAD_REQUEST - || missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY - ); + assert_eq!(missing_id.status(), StatusCode::BAD_REQUEST); let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" })) .send().await.unwrap(); diff --git a/crates/tranquil-pds/tests/sso.rs b/crates/tranquil-pds/tests/sso.rs index 915457c..4c571f2 100644 --- a/crates/tranquil-pds/tests/sso.rs +++ b/crates/tranquil-pds/tests/sso.rs @@ -40,7 +40,7 @@ async fn test_sso_initiate_invalid_provider() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.unwrap(); - assert_eq!(body["error"], "SsoProviderNotFound"); + assert_eq!(body["error"], "InvalidRequest"); } #[tokio::test] @@ -61,11 +61,7 @@ async fn test_sso_initiate_invalid_action() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.unwrap(); - assert!( - body["error"] == "SsoInvalidAction" || body["error"] == "SsoProviderNotEnabled", - "Expected SsoInvalidAction or SsoProviderNotEnabled, got: {}", - body["error"] - ); + assert_eq!(body["error"], "InvalidRequest"); } #[tokio::test] @@ -232,12 +228,12 @@ async fn test_external_identity_repository_crud() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did = unsafe { - Did::new_unchecked(format!( - "did:plc:test{}", - &uuid::Uuid::new_v4().simple().to_string()[..12] - )) - }; + let did: Did = format!( + "did:plc:test{}", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ) + .parse() + .expect("valid test DID"); let provider = SsoProviderType::Github; let provider_user_id = format!("github_user_{}", uuid::Uuid::new_v4().simple()); @@ -352,18 +348,18 @@ async fn test_external_identity_unique_constraints() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did1 = unsafe { - Did::new_unchecked(format!( - "did:plc:uc1{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )) - }; - let did2 = unsafe { - Did::new_unchecked(format!( - "did:plc:uc2{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )) - }; + let did1: Did = format!( + "did:plc:uc1{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + ) + .parse() + .expect("valid test DID"); + let did2: Did = format!( + "did:plc:uc2{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + ) + .parse() + .expect("valid test DID"); let provider_user_id = format!("unique_test_{}", uuid::Uuid::new_v4().simple()); sqlx::query!( @@ -583,13 +579,13 @@ async fn test_delete_external_identity_wrong_did() { let _url = base_url().await; let pool = get_test_db_pool().await; - let did = unsafe { - Did::new_unchecked(format!( - "did:plc:del{}", - &uuid::Uuid::new_v4().simple().to_string()[..10] - )) - }; - let wrong_did = unsafe { Did::new_unchecked("did:plc:wrongdid12345") }; + let did: Did = format!( + "did:plc:del{}", + &uuid::Uuid::new_v4().simple().to_string()[..10] + ) + .parse() + .expect("valid test DID"); + let wrong_did: Did = "did:plc:wrongdid12345".parse().expect("valid test DID"); sqlx::query!( "INSERT INTO users (did, handle, email, password_hash) VALUES ($1, $2, $3, 'hash')", diff --git a/crates/tranquil-ripple/src/transport.rs b/crates/tranquil-ripple/src/transport.rs index 1c6a9cc..938f490 100644 --- a/crates/tranquil-ripple/src/transport.rs +++ b/crates/tranquil-ripple/src/transport.rs @@ -373,8 +373,7 @@ fn configure_socket(stream: &TcpStream) { if let Err(e) = sock_ref.set_tcp_nodelay(true) { tracing::warn!(error = %e, "failed to set TCP_NODELAY"); } - let keepalive = socket2::TcpKeepalive::new() - .with_time(Duration::from_secs(30)); + let keepalive = socket2::TcpKeepalive::new().with_time(Duration::from_secs(30)); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] let keepalive = keepalive.with_interval(Duration::from_secs(10)); let params = keepalive; diff --git a/crates/tranquil-scopes/Cargo.toml b/crates/tranquil-scopes/Cargo.toml index df6360d..5636028 100644 --- a/crates/tranquil-scopes/Cargo.toml +++ b/crates/tranquil-scopes/Cargo.toml @@ -11,6 +11,7 @@ hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } urlencoding = "2" diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index 6e254f4..7bec965 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -6,6 +6,24 @@ use std::sync::LazyLock; use tokio::sync::RwLock; use tracing::{debug, warn}; +#[derive(Debug, thiserror::Error)] +pub enum ScopeExpansionError { + #[error("Invalid NSID format: {0}")] + InvalidNsid(String), + #[error("Missing definition: {0}")] + MissingDefinition(String), + #[error("Unexpected lexicon type: {0}")] + UnexpectedType(String), + #[error("DNS resolution failed: {0}")] + DnsResolution(String), + #[error("HTTP request failed: {0}")] + HttpFailed(String), + #[error("DID resolution failed: {0}")] + DidResolution(String), + #[error("No valid permissions found in permission-set")] + EmptyPermissions, +} + static LEXICON_CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -86,7 +104,10 @@ fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { .unwrap_or((rest, None)) } -async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result { +async fn expand_permission_set( + nsid: &str, + aud: Option<&str>, +) -> Result { let cache_key = match aud { Some(a) => format!("{}?aud={}", nsid, a), None => nsid.to_string(), @@ -107,25 +128,27 @@ async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result) -> Result Result { +async fn fetch_lexicon_via_atproto(nsid: &str) -> Result { let parts: Vec<&str> = nsid.split('.').collect(); if parts.len() < 3 { - return Err(format!("Invalid NSID format: {}", nsid)); + return Err(ScopeExpansionError::InvalidNsid(nsid.to_string())); } let authority = parts[..2] @@ -166,7 +189,7 @@ async fn fetch_lexicon_via_atproto(nsid: &str) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + .map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?; let url = format!( "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}", @@ -181,26 +204,26 @@ async fn fetch_lexicon_via_atproto(nsid: &str) -> Result { .header("Accept", "application/json") .send() .await - .map_err(|e| format!("Failed to fetch lexicon: {}", e))?; + .map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?; if !response.status().is_success() { - return Err(format!( - "Failed to fetch lexicon: HTTP {}", + return Err(ScopeExpansionError::HttpFailed(format!( + "HTTP {}", response.status() - )); + ))); } let record: GetRecordResponse = response .json() .await - .map_err(|e| format!("Failed to parse lexicon response: {}", e))?; + .map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?; Ok(record.value) } -async fn resolve_lexicon_did_authority(authority: &str) -> Result { +async fn resolve_lexicon_did_authority(authority: &str) -> Result { let resolver = TokioAsyncResolver::tokio_from_system_conf() - .map_err(|e| format!("Failed to create DNS resolver: {}", e))?; + .map_err(|e| ScopeExpansionError::DnsResolution(e.to_string()))?; let dns_name = format!("_lexicon.{}", authority); debug!(dns_name = %dns_name, "Looking up DNS TXT record"); @@ -208,7 +231,7 @@ async fn resolve_lexicon_did_authority(authority: &str) -> Result Result Result { +async fn resolve_did_to_pds(did: &str) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + .map_err(|e| ScopeExpansionError::HttpFailed(e.to_string()))?; let url = if did.starts_with("did:plc:") { format!("https://plc.directory/{}", did) @@ -232,7 +260,10 @@ async fn resolve_did_to_pds(did: &str) -> Result { let domain = did.strip_prefix("did:web:").unwrap(); format!("https://{}/.well-known/did.json", domain) } else { - return Err(format!("Unsupported DID method: {}", did)); + return Err(ScopeExpansionError::DidResolution(format!( + "Unsupported DID method: {}", + did + ))); }; let response = client @@ -240,22 +271,27 @@ async fn resolve_did_to_pds(did: &str) -> Result { .header("Accept", "application/json") .send() .await - .map_err(|e| format!("Failed to resolve DID: {}", e))?; + .map_err(|e| ScopeExpansionError::DidResolution(e.to_string()))?; if !response.status().is_success() { - return Err(format!("Failed to resolve DID: HTTP {}", response.status())); + return Err(ScopeExpansionError::DidResolution(format!( + "HTTP {}", + response.status() + ))); } let doc: PlcDocument = response .json() .await - .map_err(|e| format!("Failed to parse DID document: {}", e))?; + .map_err(|e| ScopeExpansionError::DidResolution(e.to_string()))?; doc.service .iter() .find(|s| s.id == "#atproto_pds") .map(|s| s.service_endpoint.clone()) - .ok_or_else(|| "No #atproto_pds service found in DID document".to_string()) + .ok_or(ScopeExpansionError::DidResolution( + "No #atproto_pds service found in DID document".to_string(), + )) } fn extract_namespace_authority(nsid: &str) -> String { diff --git a/crates/tranquil-types/src/lib.rs b/crates/tranquil-types/src/lib.rs index a59bce8..fc1e92a 100644 --- a/crates/tranquil-types/src/lib.rs +++ b/crates/tranquil-types/src/lib.rs @@ -101,10 +101,6 @@ macro_rules! simple_string_newtype { pub fn new(s: impl Into) -> Self { Self(s.into()) } - - pub fn new_unchecked(s: impl Into) -> Self { - Self(s.into()) - } } impl_string_common!($name); @@ -125,10 +121,6 @@ macro_rules! simple_string_newtype_no_sqlx { pub fn new(s: impl Into) -> Self { Self(s.into()) } - - pub fn new_unchecked(s: impl Into) -> Self { - Self(s.into()) - } } impl_string_common!($name); @@ -140,6 +132,7 @@ macro_rules! validated_string_newtype { $(#[$meta:meta])* $vis:vis struct $name:ident; error = $error:ident; + label = $label:expr; validator = $validator:expr; ) => { $(#[$meta])* @@ -165,11 +158,6 @@ macro_rules! validated_string_newtype { validator(&s).map_err(|_| $error::Invalid(s.clone()))?; Ok(Self(s)) } - - #[allow(unsafe_code, clippy::missing_safety_doc)] - pub unsafe fn new_unchecked(s: impl Into) -> Self { - Self(s.into()) - } } impl FromStr for $name { @@ -182,17 +170,27 @@ macro_rules! validated_string_newtype { impl_string_common!($name); - #[derive(Debug, Clone, thiserror::Error)] + #[derive(Debug, Clone)] pub enum $error { - #[error("invalid: {0}")] Invalid(String), } + + impl std::fmt::Display for $error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Invalid(s) => write!(f, concat!("invalid ", $label, ": {}"), s), + } + } + } + + impl std::error::Error for $error {} }; } validated_string_newtype! { pub struct Did; error = DidError; + label = "DID"; validator = |s| jacquard_common::types::string::Did::new(s).map(|_| ()).map_err(|_| ()); } @@ -217,7 +215,7 @@ impl<'de> Deserialize<'de> for Handle { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(Handle(s)) + Handle::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) } } @@ -228,11 +226,6 @@ impl Handle { .map_err(|_| HandleError::Invalid(s.clone()))?; Ok(Self(s)) } - - #[allow(unsafe_code, clippy::missing_safety_doc)] - pub unsafe fn new_unchecked(s: impl Into) -> Self { - Self(s.into()) - } } impl FromStr for Handle { @@ -368,6 +361,7 @@ pub enum AtIdentifierError { validated_string_newtype! { pub struct Rkey; error = RkeyError; + label = "rkey"; validator = |s| jacquard_common::types::string::Rkey::new(s).map(|_| ()).map_err(|_| ()); } @@ -389,6 +383,7 @@ impl Rkey { validated_string_newtype! { pub struct Nsid; error = NsidError; + label = "NSID"; validator = |s| jacquard_common::types::string::Nsid::new(s).map(|_| ()).map_err(|_| ()); } @@ -405,6 +400,7 @@ impl Nsid { validated_string_newtype! { pub struct AtUri; error = AtUriError; + label = "AT URI"; validator = |s| jacquard_common::types::string::AtUri::new(s).map(|_| ()).map_err(|_| ()); } @@ -435,6 +431,7 @@ impl AtUri { validated_string_newtype! { pub struct Tid; error = TidError; + label = "TID"; validator = |s| jacquard_common::types::string::Tid::from_str(s).map(|_| ()).map_err(|_| ()); } @@ -448,6 +445,7 @@ impl Tid { validated_string_newtype! { pub struct Datetime; error = DatetimeError; + label = "datetime"; validator = |s| jacquard_common::types::string::Datetime::from_str(s).map(|_| ()).map_err(|_| ()); } @@ -466,6 +464,7 @@ impl Datetime { validated_string_newtype! { pub struct Language; error = LanguageError; + label = "language"; validator = |s| jacquard_common::types::string::Language::from_str(s).map(|_| ()).map_err(|_| ()); } @@ -491,9 +490,8 @@ impl CidLink { Ok(Self(s)) } - #[allow(unsafe_code, clippy::missing_safety_doc)] - pub unsafe fn new_unchecked(s: impl Into) -> Self { - Self(s.into()) + pub fn from_cid(cid: &cid::Cid) -> Self { + Self(cid.to_string()) } pub fn to_cid(&self) -> Option { @@ -501,6 +499,18 @@ impl CidLink { } } +impl From for CidLink { + fn from(cid: cid::Cid) -> Self { + Self(cid.to_string()) + } +} + +impl From<&cid::Cid> for CidLink { + fn from(cid: &cid::Cid) -> Self { + Self(cid.to_string()) + } +} + impl FromStr for CidLink { type Err = CidLinkError; diff --git a/frontend/src/components/dashboard/AdminContent.svelte b/frontend/src/components/dashboard/AdminContent.svelte index 005d3f3..e2ca8af 100644 --- a/frontend/src/components/dashboard/AdminContent.svelte +++ b/frontend/src/components/dashboard/AdminContent.svelte @@ -35,16 +35,6 @@ invitesDisabled?: boolean } - interface Invite { - code: string - available: number - disabled: boolean - forAccount: string - createdBy: string - createdAt: string - uses: Array<{ usedBy: string; usedAt: string }> - } - let stats = $state(null) let users = $state([]) let loading = $state(true) @@ -52,11 +42,6 @@ let searchQuery = $state('') let usersCursor = $state(undefined) - let invites = $state([]) - let invitesLoading = $state(false) - let invitesCursor = $state(undefined) - let showInvites = $state(false) - let selectedUser = $state(null) let userActionLoading = $state(false) let userDetailLoading = $state(false) @@ -219,38 +204,6 @@ logoChanged } - async function loadInvites(reset = false) { - invitesLoading = true - if (reset) { - invites = [] - invitesCursor = undefined - } - try { - const result = await api.getInviteCodes(session.accessJwt, { - cursor: reset ? undefined : invitesCursor, - limit: 25, - }) - invites = reset ? result.codes : [...invites, ...result.codes] - invitesCursor = result.cursor - showInvites = true - } catch { - toast.error($_('admin.failedToLoadInvites')) - } finally { - invitesLoading = false - } - } - - async function disableInvite(code: string) { - if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return - try { - await api.disableInviteCodes(session.accessJwt, [code]) - invites = invites.map(i => i.code === code ? { ...i, disabled: true } : i) - toast.success($_('admin.inviteDisabled')) - } catch (e) { - toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite')) - } - } - async function showUserDetail(user: User) { selectedUser = user userDetailLoading = true @@ -467,53 +420,6 @@ {/if} -
-

{$_('admin.inviteCodes')}

-
- -
- {#if showInvites} - {#if invites.length === 0} -

{$_('admin.noInvites')}

- {:else} -
    - {#each invites as invite} -
  • -
    - {invite.code} - - {$_('admin.available')}: {invite.available} - {$_('admin.uses')}: {invite.uses.length} - {$_('admin.created')}: {formatDate(invite.createdAt)} - -
    -
    - {#if invite.disabled} - {$_('admin.disabled')} - {:else if invite.available === 0} - {$_('admin.exhausted')} - {:else} - {$_('admin.active')} - {/if} -
    -
    - {#if !invite.disabled} - - {/if} -
    -
  • - {/each} -
- {#if invitesCursor} - - {/if} - {/if} - {/if} -
{#if selectedUser} @@ -859,74 +765,6 @@ color: var(--text-secondary); } - .section-actions { - margin-bottom: var(--space-4); - } - - .invite-list { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .invite-item { - display: flex; - align-items: center; - padding: var(--space-3); - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - gap: var(--space-3); - } - - .invite-item.disabled-row { - opacity: 0.6; - } - - .invite-info { - flex: 1; - min-width: 0; - } - - .invite-code { - display: block; - font-family: var(--font-mono); - font-size: var(--text-sm); - } - - .invite-meta { - font-size: var(--text-xs); - color: var(--text-secondary); - } - - .invite-status { - flex-shrink: 0; - } - - .invite-actions { - flex-shrink: 0; - } - - .action-btn { - padding: var(--space-2) var(--space-3); - font-size: var(--text-sm); - border-radius: var(--radius-md); - cursor: pointer; - } - - .action-btn.danger { - background: transparent; - border: 1px solid var(--error-border); - color: var(--error-text); - } - - .action-btn.danger:hover { - background: var(--error-bg); - } - .modal-overlay { position: fixed; inset: 0; diff --git a/frontend/src/components/dashboard/InviteCodesContent.svelte b/frontend/src/components/dashboard/InviteCodesContent.svelte index b3e987e..3cb76b7 100644 --- a/frontend/src/components/dashboard/InviteCodesContent.svelte +++ b/frontend/src/components/dashboard/InviteCodesContent.svelte @@ -5,6 +5,7 @@ import { toast } from '../../lib/toast.svelte' import { formatDate } from '../../lib/date' import type { Session } from '../../lib/types/api' + import Skeleton from '../Skeleton.svelte' interface Props { session: Session @@ -15,6 +16,7 @@ let codes = $state([]) let loading = $state(true) let creating = $state(false) + let disablingCode = $state(null) let createdCode = $state(null) let createdCodeCopied = $state(false) let copiedCode = $state(null) @@ -60,6 +62,20 @@ } } + async function disableCode(code: string) { + if (!confirm($_('inviteCodes.disableConfirm', { values: { code } }))) return + disablingCode = code + try { + await api.disableInviteCodes(session.accessJwt, [code]) + codes = codes.map(c => c.code === code ? { ...c, disabled: true } : c) + toast.success($_('inviteCodes.disableSuccess')) + } catch (e) { + toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.disableFailed')) + } finally { + disablingCode = null + } + } + function copyCode(code: string) { navigator.clipboard.writeText(code) copiedCode = code @@ -96,7 +112,19 @@

{$_('inviteCodes.yourCodes')}

{#if loading} -
{$_('common.loading')}
+
    + {#each Array(3) as _} +
  • +
    + +
    +
    + + +
    +
  • + {/each} +
{:else if codes.length === 0}

{$_('inviteCodes.noCodes')}

{:else} @@ -174,7 +202,6 @@ margin: 0 0 var(--space-4) 0; } - .loading, .empty { color: var(--text-secondary); padding: var(--space-6); @@ -197,6 +224,10 @@ border-radius: var(--radius-lg); } + .skeleton-item { + pointer-events: none; + } + .code-item.disabled { opacity: 0.6; } @@ -224,6 +255,11 @@ flex-shrink: 0; } + .danger-text { + color: var(--error-text); + flex-shrink: 0; + } + .code-meta { display: flex; gap: var(--space-4); diff --git a/frontend/src/lib/migration/plc-ops.ts b/frontend/src/lib/migration/plc-ops.ts index a381c7e..ed7966f 100644 --- a/frontend/src/lib/migration/plc-ops.ts +++ b/frontend/src/lib/migration/plc-ops.ts @@ -7,11 +7,17 @@ import { import { P256PrivateKey, parsePrivateMultikey, + parsePublicMultikey, Secp256k1PrivateKey, Secp256k1PrivateKeyExportable, } from "@atcute/crypto"; import * as CBOR from "@atcute/cbor"; -import { fromBase16, toBase64Url } from "@atcute/multibase"; +import { + fromBase16, + fromBase58Btc, + fromBase64Url, + toBase64Url, +} from "@atcute/multibase"; export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey; @@ -36,6 +42,137 @@ export interface PlcOperationData { sig?: string; } +type KeyCurve = "secp256k1" | "p256"; + +const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i; +const BASE58BTC_CHARSET_REGEX = /^[a-km-zA-HJ-NP-Z1-9]+$/; + +const importRawBytes = ( + bytes: Uint8Array, + curve: KeyCurve, +): Promise => + curve === "p256" + ? P256PrivateKey.importRaw(bytes) + : Secp256k1PrivateKey.importRaw(bytes); + +const importFromMultikeyMatch = ( + match: ReturnType, +): Promise => + match.type === "p256" + ? P256PrivateKey.importRaw(match.privateKeyBytes) + : Secp256k1PrivateKey.importRaw(match.privateKeyBytes); + +const importJwk = async ( + json: string, + _curve: KeyCurve, +): Promise => { + const parsed: unknown = JSON.parse(json); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Invalid JWK: expected a JSON object"); + } + const jwk = parsed as Record; + + if (jwk.kty !== "EC") { + throw new Error( + `Unsupported JWK key type: ${ + String(jwk.kty) + }. Only EC keys are supported`, + ); + } + + if (typeof jwk.d !== "string") { + throw new Error( + "This JWK is a public key (missing 'd' parameter). The private key JWK is required", + ); + } + + const detectedCurve: KeyCurve = (() => { + switch (jwk.crv) { + case "secp256k1": + return "secp256k1"; + case "P-256": + return "p256"; + default: + throw new Error( + `Unsupported JWK curve: ${ + String(jwk.crv) + }. Expected secp256k1 or P-256`, + ); + } + })(); + + const privateKeyBytes = fromBase64Url(jwk.d); + return importRawBytes(privateKeyBytes, detectedCurve); +}; + +const importMultikeyOrBase58 = ( + input: string, + curve: KeyCurve, +): Promise => { + try { + const match = parsePrivateMultikey(input); + return importFromMultikeyMatch(match); + } catch { + try { + parsePublicMultikey(input); + throw new Error( + "This is a public multikey. The private key multikey is required", + ); + } catch (publicErr) { + if ( + publicErr instanceof Error && + publicErr.message.includes("public multikey") + ) { + throw publicErr; + } + } + + try { + return importBase58Raw(input, curve); + } catch { + return importBase58Raw(input.slice(1), curve); + } + } +}; + +const importBase58Raw = ( + input: string, + curve: KeyCurve, +): Promise => { + const bytes = fromBase58Btc(input); + if (bytes.length !== 32) { + throw new Error( + `Invalid base58 key: decoded to ${bytes.length} bytes, expected 32`, + ); + } + return importRawBytes(bytes, curve); +}; + +const detectAndImportPrivateKey = ( + input: string, + curve: KeyCurve, +): Promise => { + if (input.startsWith("{")) { + return importJwk(input, curve); + } + + if (HEX_PRIVATE_KEY_REGEX.test(input)) { + return importRawBytes(fromBase16(input.toLowerCase()), curve); + } + + if (input.startsWith("z")) { + return importMultikeyOrBase58(input, curve); + } + + if (BASE58BTC_CHARSET_REGEX.test(input)) { + return importBase58Raw(input, curve); + } + + throw new Error( + "Unrecognized key format. Expected hex, base58, multikey, or JWK", + ); +}; + const jsonToB64Url = (obj: unknown): string => { const enc = new TextEncoder(); const json = JSON.stringify(obj); @@ -88,42 +225,21 @@ export class PlcOps { async getKeyPair( privateKeyString: string, - type: "secp256k1" | "p256" = "secp256k1", + type: KeyCurve = "secp256k1", ): Promise { - const HEX_REGEX = /^[0-9a-f]+$/i; - const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/; - let keypair: PrivateKey | undefined; - const trimmed = privateKeyString.trim(); - if (HEX_REGEX.test(trimmed) && trimmed.length === 64) { - const privateKeyBytes = fromBase16(trimmed); - if (type === "p256") { - keypair = await P256PrivateKey.importRaw(privateKeyBytes); - } else { - keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); - } - } else if (MULTIKEY_REGEX.test(trimmed)) { - const match = parsePrivateMultikey(trimmed); - const privateKeyBytes = match.privateKeyBytes; - if (match.type === "p256") { - keypair = await P256PrivateKey.importRaw(privateKeyBytes); - } else if (match.type === "secp256k1") { - keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); - } else { - throw new Error( - `Unsupported key type: ${(match as { type: string }).type}`, - ); - } - } else { + if (trimmed.length === 0) { + throw new Error("Private key is required"); + } + + if (trimmed.startsWith("did:key:")) { throw new Error( - "Invalid key format. Expected 64-char hex or multikey format.", + "This is a did:key public key identifier. The private key is required", ); } - if (!keypair) { - throw new Error("Failed to parse private key"); - } + const keypair = await detectAndImportPrivateKey(trimmed, type); return { type: "private_key", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index d5e904c..322dde0 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -323,6 +323,10 @@ "disabled": "Disabled", "created": "Invite Code Created", "copy": "Copy", + "disable": "Disable", + "disableConfirm": "Disable invite code {code}?", + "disableSuccess": "Invite code disabled", + "disableFailed": "Failed to disable invite code", "createdOn": "Created {date}" }, "security": { @@ -504,16 +508,6 @@ "status": "Status", "created": "Created", "loadMore": "Load More", - "inviteCodes": "Invite Codes", - "loadInviteCodes": "Load Invite Codes", - "refresh": "Refresh", - "noInvites": "No invite codes found", - "available": "Available", - "uses": "Uses", - "disable": "Disable", - "disableInviteConfirm": "Disable invite code {code}?", - "active": "Active", - "exhausted": "Exhausted", "disabled": "Disabled", "userDetails": "User Details", "did": "DID", @@ -526,7 +520,6 @@ "verified": "Verified", "unverified": "Unverified", "deactivated": "Deactivated", - "inviteDisabled": "Invite code disabled", "invitesEnabled": "User invites enabled", "invitesDisabled": "User invites disabled", "userDeleted": "User account deleted", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 4772484..08b9a60 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -321,6 +321,10 @@ "disabled": "Poistettu käytöstä", "created": "Kutsukoodi luotu", "copy": "Kopioi", + "disable": "Poista käytöstä", + "disableConfirm": "Poista kutsukoodi {code} käytöstä?", + "disableSuccess": "Kutsukoodi poistettu käytöstä", + "disableFailed": "Kutsukoodin poistaminen käytöstä epäonnistui", "createdOn": "Luotu {date}", "loadFailed": "Kutsukoodien lataus epäonnistui", "createFailed": "Kutsukoodin luonti epäonnistui" @@ -498,16 +502,6 @@ "status": "Tila", "created": "Luotu", "loadMore": "Lataa lisää", - "inviteCodes": "Kutsukoodit", - "loadInviteCodes": "Lataa kutsukoodit", - "refresh": "Päivitä", - "noInvites": "Kutsukoodeja ei löytynyt", - "available": "Saatavilla", - "uses": "Käyttökerrat", - "disable": "Poista käytöstä", - "disableInviteConfirm": "Poista kutsukoodi {code} käytöstä?", - "active": "Aktiivinen", - "exhausted": "Käytetty loppuun", "disabled": "Poistettu käytöstä", "userDetails": "Käyttäjän tiedot", "did": "DID", @@ -526,7 +520,6 @@ "failedToLoadUsers": "Käyttäjien lataus epäonnistui", "searchToSeeUsers": "Hae nähdäksesi käyttäjät", "search": "Hae", - "inviteDisabled": "Kutsukoodi poistettu käytöstä", "invitesEnabled": "Käyttäjäkutsut käytössä", "invitesDisabled": "Käyttäjäkutsut pois käytöstä", "userDeleted": "Käyttäjätili poistettu", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index bec5e9a..b445726 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -321,6 +321,10 @@ "disabled": "無効", "created": "招待コードを作成しました", "copy": "コピー", + "disable": "無効化", + "disableConfirm": "招待コード {code} を無効にしますか?", + "disableSuccess": "招待コードを無効にしました", + "disableFailed": "招待コードの無効化に失敗しました", "createdOn": "{date} に作成", "loadFailed": "招待コードの読み込みに失敗しました", "createFailed": "招待コードの作成に失敗しました" @@ -498,16 +502,6 @@ "status": "ステータス", "created": "作成日時", "loadMore": "さらに読み込む", - "inviteCodes": "招待コード", - "loadInviteCodes": "招待コードを読み込む", - "refresh": "更新", - "noInvites": "招待コードが見つかりません", - "available": "利用可能", - "uses": "使用回数", - "disable": "無効化", - "disableInviteConfirm": "招待コード {code} を無効にしますか?", - "active": "アクティブ", - "exhausted": "使用済み", "disabled": "無効", "userDetails": "ユーザー詳細", "did": "DID", @@ -526,7 +520,6 @@ "failedToLoadUsers": "ユーザーの読み込みに失敗しました", "searchToSeeUsers": "検索してユーザーを表示", "search": "検索", - "inviteDisabled": "招待コードを無効にしました", "invitesEnabled": "ユーザー招待を有効にしました", "invitesDisabled": "ユーザー招待を無効にしました", "userDeleted": "ユーザーアカウントを削除しました", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index 8ff382e..730a260 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -321,6 +321,10 @@ "disabled": "비활성화됨", "created": "초대 코드가 생성되었습니다", "copy": "복사", + "disable": "비활성화", + "disableConfirm": "초대 코드 {code}을(를) 비활성화하시겠습니까?", + "disableSuccess": "초대 코드가 비활성화되었습니다", + "disableFailed": "초대 코드 비활성화 실패", "createdOn": "{date}에 생성됨", "loadFailed": "초대 코드 로딩 실패", "createFailed": "초대 코드 생성 실패" @@ -498,16 +502,6 @@ "status": "상태", "created": "생성일", "loadMore": "더 불러오기", - "inviteCodes": "초대 코드", - "loadInviteCodes": "초대 코드 불러오기", - "refresh": "새로고침", - "noInvites": "초대 코드가 없습니다", - "available": "사용 가능", - "uses": "사용 횟수", - "disable": "비활성화", - "disableInviteConfirm": "초대 코드 {code}을(를) 비활성화하시겠습니까?", - "active": "활성", - "exhausted": "소진됨", "disabled": "비활성화됨", "userDetails": "사용자 세부 정보", "did": "DID", @@ -526,7 +520,6 @@ "failedToLoadUsers": "사용자 로딩 실패", "searchToSeeUsers": "검색하여 사용자 보기", "search": "검색", - "inviteDisabled": "초대 코드가 비활성화되었습니다", "invitesEnabled": "사용자 초대가 활성화되었습니다", "invitesDisabled": "사용자 초대가 비활성화되었습니다", "userDeleted": "사용자 계정이 삭제되었습니다", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 70d4cce..f3403d4 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -320,6 +320,10 @@ "disabled": "Inaktiverad", "created": "Inbjudningskod skapad", "copy": "Kopiera", + "disable": "Inaktivera", + "disableConfirm": "Inaktivera inbjudningskod {code}?", + "disableSuccess": "Inbjudningskod inaktiverad", + "disableFailed": "Kunde inte inaktivera inbjudningskod", "createdOn": "Skapad {date}", "spent": "Förbrukad", "loadFailed": "Kunde inte ladda inbjudningskoder", @@ -498,16 +502,6 @@ "status": "Status", "created": "Skapad", "loadMore": "Ladda fler", - "inviteCodes": "Inbjudningskoder", - "loadInviteCodes": "Ladda inbjudningskoder", - "refresh": "Uppdatera", - "noInvites": "Inga inbjudningskoder hittades", - "available": "Tillgänglig", - "uses": "Användningar", - "disable": "Inaktivera", - "disableInviteConfirm": "Inaktivera inbjudningskod {code}?", - "active": "Aktiv", - "exhausted": "Förbrukad", "disabled": "Inaktiverad", "userDetails": "Användardetaljer", "did": "DID", @@ -526,7 +520,6 @@ "failedToLoadUsers": "Kunde inte ladda användare", "searchToSeeUsers": "Sök för att visa användare", "search": "Sök", - "inviteDisabled": "Inbjudningskod inaktiverad", "invitesEnabled": "Användarinbjudningar aktiverade", "invitesDisabled": "Användarinbjudningar inaktiverade", "userDeleted": "Användarkonto raderat", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 5659fea..cf050d3 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -320,6 +320,10 @@ "disabled": "已禁用", "created": "邀请码已创建", "copy": "复制", + "disable": "禁用", + "disableConfirm": "禁用邀请码 {code}?", + "disableSuccess": "邀请码已禁用", + "disableFailed": "禁用邀请码失败", "createdOn": "创建于 {date}", "spent": "已使用", "loadFailed": "加载邀请码失败", @@ -500,16 +504,6 @@ "status": "状态", "created": "创建时间", "loadMore": "加载更多", - "inviteCodes": "邀请码", - "loadInviteCodes": "加载邀请码", - "refresh": "刷新", - "noInvites": "暂无邀请码", - "available": "可用", - "uses": "使用次数", - "disable": "禁用", - "disableInviteConfirm": "禁用邀请码 {code}?", - "active": "活跃", - "exhausted": "已用完", "disabled": "已禁用", "userDetails": "用户详情", "did": "DID", @@ -526,7 +520,6 @@ "failedToLoadUsers": "加载用户失败", "searchToSeeUsers": "搜索以查看用户", "search": "搜索", - "inviteDisabled": "邀请码已禁用", "invitesEnabled": "用户邀请已启用", "invitesDisabled": "用户邀请已禁用", "userDeleted": "用户账户已删除", diff --git a/frontend/src/tests/migration/plc-ops.test.ts b/frontend/src/tests/migration/plc-ops.test.ts index bd0d807..e52fe76 100644 --- a/frontend/src/tests/migration/plc-ops.test.ts +++ b/frontend/src/tests/migration/plc-ops.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PlcOps, plcOps } from "../../lib/migration/plc-ops.ts"; +import { + P256PrivateKeyExportable, + Secp256k1PrivateKeyExportable, +} from "@atcute/crypto"; +import { fromBase58Btc, toBase58Btc } from "@atcute/multibase"; describe("migration/plc-ops", () => { beforeEach(() => { @@ -89,15 +94,222 @@ describe("migration/plc-ops", () => { it("throws for invalid key format", async () => { await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow( - "Invalid key format", + "Unrecognized key format", ); }); it("throws for hex key with wrong length", async () => { - await expect(plcOps.getKeyPair("abc123")).rejects.toThrow( - "Invalid key format", + await expect(plcOps.getKeyPair("abc123")).rejects.toThrow(); + }); + }); + + describe("getKeyPair - multikey round-trip", () => { + it("round-trips from createNewSecp256k1Keypair", async () => { + const { privateKey, publicKey } = await plcOps + .createNewSecp256k1Keypair(); + + const result = await plcOps.getKeyPair(privateKey); + + expect(result.didPublicKey).toBe(publicKey); + }); + + it("produces correct multikey structure (z prefix, codec bytes)", async () => { + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); + + expect(privateKey.startsWith("z")).toBe(true); + const decoded = fromBase58Btc(privateKey.slice(1)); + expect(decoded[0]).toBe(0x81); + expect(decoded[1]).toBe(0x26); + expect(decoded.length).toBe(34); + }); + + it("multikey import matches hex import of same raw bytes", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const multikey = await keypair.exportPrivateKey("multikey"); + const rawHex = await keypair.exportPrivateKey("rawHex"); + + const fromMultikey = await plcOps.getKeyPair(multikey); + const fromHex = await plcOps.getKeyPair(rawHex); + + expect(fromMultikey.didPublicKey).toBe(fromHex.didPublicKey); + }); + }); + + describe("getKeyPair - hex format", () => { + it("accepts uppercase hex", async () => { + const result = await plcOps.getKeyPair("A".repeat(64)); + + expect(result.type).toBe("private_key"); + expect(result.didPublicKey.startsWith("did:key:")).toBe(true); + }); + }); + + describe("getKeyPair - JWK format", () => { + it("imports secp256k1 JWK with d parameter", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const jwk = await keypair.exportPrivateKey("jwk"); + const expectedDid = await keypair.exportPublicKey("did"); + + const result = await plcOps.getKeyPair(JSON.stringify(jwk)); + + expect(result.didPublicKey).toBe(expectedDid); + }); + + it("imports P-256 JWK with d parameter", async () => { + const keypair = await P256PrivateKeyExportable.createKeypair(); + const jwk = await keypair.exportPrivateKey("jwk"); + const expectedDid = await keypair.exportPublicKey("did"); + + const result = await plcOps.getKeyPair(JSON.stringify(jwk)); + + expect(result.didPublicKey).toBe(expectedDid); + }); + + it("rejects JWK without d (public key)", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const jwk = await keypair.exportPublicKey("jwk"); + + await expect( + plcOps.getKeyPair(JSON.stringify(jwk)), + ).rejects.toThrow("public key"); + }); + + it("rejects unsupported kty", async () => { + const jwk = { kty: "RSA", n: "abc", e: "AQAB" }; + + await expect(plcOps.getKeyPair(JSON.stringify(jwk))).rejects.toThrow( + "Unsupported JWK key type", ); }); + + it("rejects unsupported crv", async () => { + const jwk = { kty: "EC", crv: "P-384", d: "AAAA", x: "BBBB", y: "CCCC" }; + + await expect(plcOps.getKeyPair(JSON.stringify(jwk))).rejects.toThrow( + "Unsupported JWK curve", + ); + }); + + it("rejects malformed JSON", async () => { + await expect(plcOps.getKeyPair("{not valid json")).rejects.toThrow(); + }); + + it("produces same public key as hex import of same raw bytes", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const jwk = await keypair.exportPrivateKey("jwk"); + const rawHex = await keypair.exportPrivateKey("rawHex"); + + const fromJwk = await plcOps.getKeyPair(JSON.stringify(jwk)); + const fromHex = await plcOps.getKeyPair(rawHex); + + expect(fromJwk.didPublicKey).toBe(fromHex.didPublicKey); + }); + }); + + describe("getKeyPair - plain base58 format", () => { + it("imports base58-encoded 32-byte raw key", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const rawBytes = await keypair.exportPrivateKey("raw"); + const base58 = toBase58Btc(rawBytes); + const expectedDid = await keypair.exportPublicKey("did"); + + const result = await plcOps.getKeyPair(base58); + + expect(result.didPublicKey).toBe(expectedDid); + }); + + it("produces same public key as hex import of same raw bytes", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const rawBytes = await keypair.exportPrivateKey("raw"); + const rawHex = await keypair.exportPrivateKey("rawHex"); + const base58 = toBase58Btc(rawBytes); + + const fromBase58 = await plcOps.getKeyPair(base58); + const fromHex = await plcOps.getKeyPair(rawHex); + + expect(fromBase58.didPublicKey).toBe(fromHex.didPublicKey); + }); + + it("rejects wrong decoded length", async () => { + const shortBytes = new Uint8Array(16); + crypto.getRandomValues(shortBytes); + const base58Short = toBase58Btc(shortBytes); + + await expect(plcOps.getKeyPair(base58Short)).rejects.toThrow( + "expected 32", + ); + }); + }); + + describe("getKeyPair - cross-format consistency", () => { + it("hex, multikey, and JWK all produce identical did:key", async () => { + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); + const rawHex = await keypair.exportPrivateKey("rawHex"); + const multikey = await keypair.exportPrivateKey("multikey"); + const jwk = await keypair.exportPrivateKey("jwk"); + + const [fromHex, fromMultikey, fromJwk] = await Promise.all([ + plcOps.getKeyPair(rawHex), + plcOps.getKeyPair(multikey), + plcOps.getKeyPair(JSON.stringify(jwk)), + ]); + + expect(fromHex.didPublicKey).toBe(fromMultikey.didPublicKey); + expect(fromHex.didPublicKey).toBe(fromJwk.didPublicKey); + }); + + it("hex, multikey, JWK, and base58 all match for P-256", async () => { + const keypair = await P256PrivateKeyExportable.createKeypair(); + const rawHex = await keypair.exportPrivateKey("rawHex"); + const multikey = await keypair.exportPrivateKey("multikey"); + const jwk = await keypair.exportPrivateKey("jwk"); + const rawBytes = await keypair.exportPrivateKey("raw"); + const base58 = toBase58Btc(rawBytes); + + const [fromHex, fromMultikey, fromJwk, fromBase58] = await Promise.all([ + plcOps.getKeyPair(rawHex, "p256"), + plcOps.getKeyPair(multikey), + plcOps.getKeyPair(JSON.stringify(jwk)), + plcOps.getKeyPair(base58, "p256"), + ]); + + expect(fromHex.didPublicKey).toBe(fromMultikey.didPublicKey); + expect(fromHex.didPublicKey).toBe(fromJwk.didPublicKey); + expect(fromHex.didPublicKey).toBe(fromBase58.didPublicKey); + }); + }); + + describe("getKeyPair - error cases", () => { + it("rejects empty string", async () => { + await expect(plcOps.getKeyPair("")).rejects.toThrow( + "Private key is required", + ); + }); + + it("rejects whitespace-only", async () => { + await expect(plcOps.getKeyPair(" ")).rejects.toThrow( + "Private key is required", + ); + }); + + it("rejects did:key: prefix with helpful error", async () => { + await expect( + plcOps.getKeyPair( + "did:key:zQ3shunBKoL5VRgSEX7RQGQEG3TTo6MPVWvT7tcVjjwZCWMEE", + ), + ).rejects.toThrow("public key"); + }); + + it("rejects unrecognized garbage", async () => { + await expect(plcOps.getKeyPair("!!!invalid!!!")).rejects.toThrow( + "Unrecognized key format", + ); + }); + + it("rejects hex with non-hex chars in 64-char string", async () => { + const almostHex = "g".repeat(64); + await expect(plcOps.getKeyPair(almostHex)).rejects.toThrow(); + }); }); describe("pushPlcOperation", () => {