An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(provisioning): require password at DID ceremony

- POST /v1/dids now requires a password field; relay hashes with argon2id
and stores the PHC string in accounts.password_hash
- perform_did_ceremony Tauri command updated to accept password: String and
include it in the relay request body
- New PasswordScreen.svelte collects and confirms password during onboarding
(min 8 chars, confirm-match validation)
- Onboarding state machine extended: handle → password → loading → did_ceremony
- Integration test: password-provisioned account can authenticate via createSession

+298 -23
+4 -4
apps/identity-wallet/CLAUDE.md
··· 1 1 # Identity Wallet Mobile App 2 2 3 - Last verified: 2026-03-21 3 + Last verified: 2026-03-23 4 4 5 5 ## Purpose 6 6 ··· 12 12 13 13 **Exposes:** 14 14 - `src/lib/ipc.ts` — typed wrappers for all Tauri IPC commands; import these instead of calling `invoke()` directly. Exports: `createAccount()`, `getOrCreateDeviceKey()`, `signWithDeviceKey()`, `performDIDCeremony()`, and their associated types (`DevicePublicKey`, `DeviceKeyError`, `CreateAccountResult`, `CreateAccountError`, `DIDCeremonyResult`, `DIDCeremonyError`) 15 - - `src/lib/components/onboarding/` — eight onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen) 16 - - `src/routes/+page.svelte` — root page: nine-step onboarding state machine (welcome -> claim_code -> email -> handle -> loading -> did_ceremony -> did_success -> shamir_backup -> complete) 15 + - `src/lib/components/onboarding/` — nine onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, PasswordScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen) 16 + - `src/routes/+page.svelte` — root page: ten-step onboarding state machine (welcome -> claim_code -> email -> handle -> password -> loading -> did_ceremony -> did_success -> shamir_backup -> complete) 17 17 18 18 **Guarantees:** 19 19 - SSR is disabled globally (`ssr = false` in `src/routes/+layout.ts`); the frontend is a fully static SPA loaded from disk by WKWebView ··· 31 31 - `src/lib.rs::create_account(claim_code, email, handle) -> Result<CreateAccountResult, CreateAccountError>` — Tauri IPC command: gets or creates device key via `device_key::get_or_create()`, POSTs to relay `/v1/accounts/mobile`, stores tokens in Keychain on success 32 32 - `src/lib.rs::get_or_create_device_key() -> Result<DevicePublicKey, DeviceKeyError>` — Tauri IPC command: delegates to `device_key::get_or_create()` 33 33 - `src/lib.rs::sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, DeviceKeyError>` — Tauri IPC command: delegates to `device_key::sign()` 34 - - `src/lib.rs::perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op to relay (POST /v1/dids with Bearer token), persists DID + upgraded session token + Share 1 in Keychain, returns `{ did, share3 }` to frontend 34 + - `src/lib.rs::perform_did_ceremony(handle: String, password: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op + password to relay (POST /v1/dids with Bearer token), persists DID + upgraded session token + Share 1 in Keychain, returns `{ did, share3 }` to frontend 35 35 - `src/device_key.rs` — P-256 device key management with `#[cfg]`-based dispatch: macOS/simulator uses software keys via `crypto` crate + Keychain storage; real iOS device uses Secure Enclave via `security-framework`. Public API: `get_or_create() -> Result<DevicePublicKey, DeviceKeyError>` (idempotent), `sign(data) -> Result<Vec<u8>, DeviceKeyError>` 36 36 - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"` 37 37 - `src/http.rs` — `RelayClient` with compile-time base URL (localhost:8080 debug, relay.ezpds.com release); methods: `post()`, `get()`, `post_with_bearer()`; static `base_url()` accessor
+21 -1
apps/identity-wallet/src-tauri/src/lib.rs
··· 50 50 struct CreateDidRequest { 51 51 rotation_key_public: String, 52 52 signed_creation_op: serde_json::Value, 53 + /// Initial password stored as an argon2id PHC string by the relay. 54 + password: String, 53 55 } 54 56 55 57 /// Response from POST /v1/dids — the promoted DID, upgraded session token, and Shamir shares. ··· 261 263 } 262 264 263 265 #[tauri::command] 264 - async fn perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError> { 266 + async fn perform_did_ceremony( 267 + handle: String, 268 + password: String, 269 + ) -> Result<DIDCeremonyResult, DIDCeremonyError> { 265 270 // Step 1: Get or create the device's P-256 key (serves as rotation key). 266 271 let device_key = device_key::get_or_create().map_err(|e| { 267 272 tracing::warn!(error = %e, "device key creation failed during DID ceremony"); ··· 329 334 tracing::error!(error = %e, "genesis op JSON is not valid JSON"); 330 335 DIDCeremonyError::SigningFailed 331 336 })?, 337 + password, 332 338 }; 333 339 334 340 let resp = RELAY_CLIENT ··· 399 405 #[cfg(test)] 400 406 mod tests { 401 407 use super::*; 408 + 409 + // -- CreateDidRequest serialization -- 410 + #[test] 411 + fn create_did_request_serializes_password_and_camel_case() { 412 + let req = CreateDidRequest { 413 + rotation_key_public: "did:key:z123".into(), 414 + signed_creation_op: serde_json::json!({"type": "plc_operation"}), 415 + password: "mysecretpassword".into(), 416 + }; 417 + let json = serde_json::to_value(&req).unwrap(); 418 + assert_eq!(json["rotationKeyPublic"], "did:key:z123"); 419 + assert_eq!(json["password"], "mysecretpassword"); 420 + assert!(json["signedCreationOp"].is_object()); 421 + } 402 422 403 423 // -- CreateMobileAccountRequest serialization -- 404 424 #[test]
+3 -1
apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte
··· 5 5 6 6 let { 7 7 handle, 8 + password, 8 9 onsuccess, 9 10 }: { 10 11 handle: string; 12 + password: string; 11 13 onsuccess: (result: import('$lib/ipc').DIDCeremonyResult) => void; 12 14 } = $props(); 13 15 ··· 18 20 loading = true; 19 21 error = null; 20 22 try { 21 - const result = await performDIDCeremony(handle); 23 + const result = await performDIDCeremony(handle, password); 22 24 loading = false; 23 25 onsuccess(result); 24 26 } catch (raw: unknown) {
+106
apps/identity-wallet/src/lib/components/onboarding/PasswordScreen.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + value = $bindable(''), 4 + onnext, 5 + }: { 6 + value: string; 7 + onnext: () => void; 8 + } = $props(); 9 + 10 + let confirm = $state(''); 11 + 12 + let mismatchError = $derived( 13 + confirm.length > 0 && value !== confirm ? "Passwords don't match." : undefined 14 + ); 15 + 16 + let isValid = $derived(value.length >= 8 && value === confirm); 17 + </script> 18 + 19 + <div class="screen"> 20 + <h2>Create a Password</h2> 21 + <p class="hint">You'll use this to sign in to your account.</p> 22 + 23 + <input 24 + type="password" 25 + placeholder="Password" 26 + autocomplete="new-password" 27 + bind:value 28 + /> 29 + 30 + <input 31 + type="password" 32 + class:error={!!mismatchError} 33 + placeholder="Confirm password" 34 + autocomplete="new-password" 35 + bind:value={confirm} 36 + /> 37 + 38 + {#if mismatchError} 39 + <p class="error-text">{mismatchError}</p> 40 + {/if} 41 + 42 + <button disabled={!isValid} onclick={onnext}>Continue</button> 43 + </div> 44 + 45 + <style> 46 + .screen { 47 + display: flex; 48 + flex-direction: column; 49 + align-items: center; 50 + padding: 2rem; 51 + gap: 1rem; 52 + height: 100%; 53 + justify-content: center; 54 + } 55 + 56 + h2 { 57 + font-size: 1.5rem; 58 + font-weight: 700; 59 + margin: 0; 60 + } 61 + 62 + .hint { 63 + font-size: 0.9rem; 64 + color: #6b7280; 65 + text-align: center; 66 + margin: 0; 67 + } 68 + 69 + input { 70 + width: 100%; 71 + max-width: 320px; 72 + padding: 1rem; 73 + font-size: 1rem; 74 + border: 2px solid #d1d5db; 75 + border-radius: 12px; 76 + } 77 + 78 + input.error { 79 + border-color: #ef4444; 80 + } 81 + 82 + .error-text { 83 + color: #ef4444; 84 + font-size: 0.875rem; 85 + margin: 0; 86 + text-align: center; 87 + } 88 + 89 + button { 90 + width: 100%; 91 + max-width: 320px; 92 + padding: 1rem; 93 + background: #007aff; 94 + color: #fff; 95 + border: none; 96 + border-radius: 12px; 97 + font-size: 1rem; 98 + font-weight: 600; 99 + cursor: pointer; 100 + } 101 + 102 + button:disabled { 103 + background: #9ca3af; 104 + cursor: not-allowed; 105 + } 106 + </style>
+5 -2
apps/identity-wallet/src/lib/ipc.ts
··· 150 150 * On success, the DID and new session token are stored in Keychain by the Rust backend. 151 151 * On failure, the Promise rejects with a `DIDCeremonyError`. 152 152 */ 153 - export const performDIDCeremony = (handle: string): Promise<DIDCeremonyResult> => 154 - invoke('perform_did_ceremony', { handle }); 153 + export const performDIDCeremony = ( 154 + handle: string, 155 + password: string, 156 + ): Promise<DIDCeremonyResult> => 157 + invoke('perform_did_ceremony', { handle, password });
+9 -1
apps/identity-wallet/src/routes/+page.svelte
··· 3 3 import ClaimCodeScreen from '$lib/components/onboarding/ClaimCodeScreen.svelte'; 4 4 import EmailScreen from '$lib/components/onboarding/EmailScreen.svelte'; 5 5 import HandleScreen from '$lib/components/onboarding/HandleScreen.svelte'; 6 + import PasswordScreen from '$lib/components/onboarding/PasswordScreen.svelte'; 6 7 import LoadingScreen from '$lib/components/onboarding/LoadingScreen.svelte'; 7 8 import DIDCeremonyScreen from '$lib/components/onboarding/DIDCeremonyScreen.svelte'; 8 9 import DIDSuccessScreen from '$lib/components/onboarding/DIDSuccessScreen.svelte'; ··· 24 25 | 'claim_code' 25 26 | 'email' 26 27 | 'handle' 28 + | 'password' 27 29 | 'loading' 28 30 | 'did_ceremony' 29 31 | 'did_success' ··· 33 35 // ── State ──────────────────────────────────────────────────────────────── 34 36 35 37 let step = $state<OnboardingStep>('welcome'); 36 - let form = $state({ claimCode: '', email: '', handle: '', did: '', share3: '' }); 38 + let form = $state({ claimCode: '', email: '', handle: '', password: '', did: '', share3: '' }); 37 39 38 40 /** 39 41 * Per-field error messages displayed by each screen. ··· 136 138 <HandleScreen 137 139 bind:value={form.handle} 138 140 error={errors.handle} 141 + onnext={() => goTo('password')} 142 + /> 143 + {:else if step === 'password'} 144 + <PasswordScreen 145 + bind:value={form.password} 139 146 onnext={submitAccount} 140 147 /> 141 148 {:else if step === 'loading'} ··· 143 150 {:else if step === 'did_ceremony'} 144 151 <DIDCeremonyScreen 145 152 handle={form.handle} 153 + password={form.password} 146 154 onsuccess={(result) => { form.did = result.did; form.share3 = result.share3; step = 'did_success'; }} 147 155 /> 148 156 {:else if step === 'did_success'}
+2 -1
bruno/create-did.bru
··· 17 17 body:json { 18 18 { 19 19 "rotationKeyPublic": "{{rotationKeyPublic}}", 20 - "signedCreationOp": {{signedCreationOp}} 20 + "signedCreationOp": {{signedCreationOp}}, 21 + "password": "{{password}}" 21 22 } 22 23 }
+148 -13
crates/relay/src/routes/create_did.rs
··· 6 6 // - Authorization: Bearer <pending_session_token> 7 7 // - JSON body: { 8 8 // "rotationKeyPublic": "did:key:z...", 9 - // "signedCreationOp": { ...genesis op fields... } 9 + // "signedCreationOp": { ...genesis op fields... }, 10 + // "password": "<plaintext>" // required; stored as argon2id PHC string 10 11 // } 11 12 // 12 13 // Processing steps: ··· 26 27 // 9. If !skip_plc: POST {plc_directory_url}/{did} with signed_op_str 27 28 // 10. build_did_document(&verified) → serde_json::Value 28 29 // 11. Generate session token: 32 random bytes → base64url (returned) + SHA-256 hex (stored) 29 - // 12. Atomic transaction: 30 - // INSERT accounts (did, email, password_hash=NULL, recovery_share=pending_share_2) 30 + // 12. Hash password with argon2id → password_hash 31 + // 13. Atomic transaction: 32 + // INSERT accounts (did, email, password_hash=argon2id(password), recovery_share=pending_share_2) 31 33 // INSERT did_documents (did, document) 32 34 // INSERT sessions (id, did, device_id=NULL, token_hash, expires_at=+1 year) 33 35 // DELETE pending_sessions WHERE account_id = ? 34 36 // DELETE devices WHERE account_id = ? 35 37 // DELETE pending_accounts WHERE id = ? 36 - // 13. Return { "did", "did_document", "status": "active", "session_token", 38 + // 14. Return { "did", "did_document", "status": "active", "session_token", 37 39 // "shamir_share_1": <base32>, "shamir_share_3": <base32> } 38 40 // 39 41 // Note: handles are NOT inserted here. Handle creation is the caller's responsibility ··· 44 46 // Outputs (error): 400 INVALID_CLAIM, 401 UNAUTHORIZED, 409 DID_ALREADY_EXISTS, 45 47 // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 46 48 49 + use argon2::{ 50 + password_hash::{rand_core::OsRng as ArgonOsRng, SaltString}, 51 + Argon2, PasswordHasher, 52 + }; 47 53 use axum::{extract::State, http::HeaderMap, Json}; 48 54 use data_encoding::BASE32_NOPAD; 49 55 use rand_core::{OsRng, RngCore}; ··· 61 67 pub struct CreateDidRequest { 62 68 pub rotation_key_public: String, 63 69 pub signed_creation_op: serde_json::Value, 70 + /// Initial password, stored as an argon2id PHC string. 71 + /// Enables `createSession` for this account after promotion. 72 + pub password: String, 64 73 } 65 74 66 75 #[derive(Serialize)] ··· 107 116 .await?; 108 117 } 109 118 110 - // Phase 4: Build DID document, generate session, atomically promote. 119 + // Phase 4: Build DID document, generate session, hash password, atomically promote. 111 120 let did_document = build_did_document(&verified)?; 112 121 let session_token = generate_token(); 122 + let password_hash = hash_password(&payload.password)?; 113 123 promote_account( 114 124 &state.db, 115 125 did, ··· 118 128 &did_document, 119 129 &session_token.hash, 120 130 &share2, 131 + &password_hash, 121 132 ) 122 133 .await?; 123 134 ··· 129 140 shamir_share_1: share1, 130 141 shamir_share_3: share3, 131 142 })) 143 + } 144 + 145 + // ── Private helpers ─────────────────────────────────────────────────────────── 146 + 147 + /// Hash `password` with argon2id and return the PHC string. 148 + /// 149 + /// Uses `Argon2::default()` (argon2id, m=19456, t=2, p=1) with a freshly generated 150 + /// random salt. The PHC string embeds the algorithm, parameters, salt, and hash — 151 + /// everything `verify_password` in `create_session.rs` needs for later verification. 152 + fn hash_password(password: &str) -> Result<String, ApiError> { 153 + let salt = SaltString::generate(&mut ArgonOsRng); 154 + Argon2::default() 155 + .hash_password(password.as_bytes(), &salt) 156 + .map(|h| h.to_string()) 157 + .map_err(|e| { 158 + tracing::error!(error = %e, "argon2id hashing failed"); 159 + ApiError::new(ErrorCode::InternalError, "failed to process password") 160 + }) 132 161 } 133 162 134 163 // ── Phase helpers ───────────────────────────────────────────────────────────── ··· 421 450 Ok(()) 422 451 } 423 452 424 - /// Atomically promote a pending account to a full account (Steps 10-12). 453 + /// Atomically promote a pending account to a full account (Steps 10-13). 425 454 /// 426 455 /// In a single transaction: INSERT accounts + did_documents + sessions, 427 456 /// then DELETE pending_sessions + devices + pending_accounts. 428 457 /// `recovery_share` is Share 2 of the Shamir split; stored for relay-side custody. 458 + /// `password_hash` is the argon2id PHC string for the account's password set during the ceremony. 429 459 async fn promote_account( 430 460 db: &sqlx::SqlitePool, 431 461 did: &str, ··· 434 464 did_document: &serde_json::Value, 435 465 token_hash: &str, 436 466 recovery_share: &str, 467 + password_hash: &str, 437 468 ) -> Result<(), ApiError> { 438 469 let did_document_str = serde_json::to_string(did_document).map_err(|e| { 439 470 tracing::error!(error = %e, "failed to serialize DID document"); ··· 449 480 450 481 sqlx::query( 451 482 "INSERT INTO accounts (did, email, password_hash, recovery_share, created_at, updated_at) \ 452 - VALUES (?, ?, NULL, ?, datetime('now'), datetime('now'))", 483 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))", 453 484 ) 454 485 .bind(did) 455 486 .bind(email) 487 + .bind(password_hash) 456 488 .bind(recovery_share) 457 489 .execute(&mut *tx) 458 490 .await ··· 688 720 test_state_with_plc_url(plc_url).await 689 721 } 690 722 691 - /// Build a POST /v1/dids request. 723 + /// Build a POST /v1/dids request with a default test password. 692 724 fn create_did_request( 693 725 session_token: &str, 694 726 rotation_key_public: &str, ··· 697 729 let body = serde_json::json!({ 698 730 "rotationKeyPublic": rotation_key_public, 699 731 "signedCreationOp": signed_creation_op, 732 + "password": "test-password", 700 733 }); 701 734 Request::builder() 702 735 .method("POST") ··· 813 846 "serviceEndpoint should match config.public_url" 814 847 ); 815 848 816 - // accounts row with correct did, email; password_hash IS NULL; recovery_share persisted. 849 + // accounts row with correct did, email; password_hash is a non-NULL argon2id PHC string; 850 + // recovery_share persisted. 817 851 let row: Option<(String, Option<String>, Option<String>)> = sqlx::query_as( 818 852 "SELECT email, password_hash, recovery_share FROM accounts WHERE did = ?", 819 853 ) ··· 823 857 .unwrap(); 824 858 let (email, password_hash, recovery_share) = row.expect("accounts row should exist"); 825 859 assert!(email.contains("alice"), "email should match test account"); 860 + let hash_str = password_hash.expect("password_hash should not be NULL after DID ceremony"); 826 861 assert!( 827 - password_hash.is_none(), 828 - "password_hash should be NULL for device-provisioned account" 862 + hash_str.starts_with("$argon2id$"), 863 + "password_hash should be an argon2id PHC string, got: {hash_str}" 829 864 ); 830 865 let rs = recovery_share 831 866 .as_deref() ··· 1185 1220 1186 1221 let request_body = serde_json::json!({ 1187 1222 "rotationKeyPublic": "not-a-did-key", 1188 - "signedCreationOp": serde_json::json!({}) 1223 + "signedCreationOp": serde_json::json!({}), 1224 + "password": "test-password", 1189 1225 }); 1190 1226 1191 1227 let request = Request::builder() ··· 1266 1302 .body(Body::from( 1267 1303 serde_json::json!({ 1268 1304 "rotationKeyPublic": "did:key:z123", 1269 - "signedCreationOp": signed_op 1305 + "signedCreationOp": signed_op, 1306 + "password": "test-password", 1270 1307 }) 1271 1308 .to_string(), 1272 1309 )) ··· 1281 1318 .unwrap(); 1282 1319 let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 1283 1320 assert_eq!(body["error"]["code"], "UNAUTHORIZED"); 1321 + } 1322 + 1323 + // ── Password provisioning ───────────────────────────────────────────────── 1324 + 1325 + /// Account promoted with a password can authenticate via createSession. 1326 + /// 1327 + /// Uses the retry path (pre-stored pending_did) so plc.directory is never called, 1328 + /// making the test runnable without network access. 1329 + #[tokio::test] 1330 + async fn with_password_account_can_call_create_session() { 1331 + // Use any URL — plc.directory will not be contacted on the retry path. 1332 + let state = test_state_for_did("https://plc.directory".to_string()).await; 1333 + let db = state.db.clone(); 1334 + let setup = insert_test_data(&db).await; 1335 + let (rotation_key_public, signed_op) = 1336 + make_signed_op(&setup.handle, &state.config.public_url); 1337 + 1338 + // Derive DID and pre-store it with dummy shares to trigger the skip_plc path. 1339 + let signed_op_str = serde_json::to_string(&signed_op).unwrap(); 1340 + let verified = crypto::verify_genesis_op( 1341 + &signed_op_str, 1342 + &crypto::DidKeyUri(rotation_key_public.clone()), 1343 + ) 1344 + .expect("verify should succeed"); 1345 + sqlx::query( 1346 + "UPDATE pending_accounts \ 1347 + SET pending_did = ?, pending_share_1 = ?, pending_share_2 = ?, pending_share_3 = ? \ 1348 + WHERE id = ?", 1349 + ) 1350 + .bind(&verified.did) 1351 + .bind("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") 1352 + .bind("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB") 1353 + .bind("CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC") 1354 + .bind(&setup.account_id) 1355 + .execute(&db) 1356 + .await 1357 + .expect("pre-store pending_did and shares"); 1358 + 1359 + // POST /v1/dids with a password. 1360 + let body = serde_json::json!({ 1361 + "rotationKeyPublic": rotation_key_public, 1362 + "signedCreationOp": signed_op, 1363 + "password": "mysecretpassword", 1364 + }); 1365 + let request = Request::builder() 1366 + .method("POST") 1367 + .uri("/v1/dids") 1368 + .header("Authorization", format!("Bearer {}", setup.session_token)) 1369 + .header("Content-Type", "application/json") 1370 + .body(Body::from(body.to_string())) 1371 + .unwrap(); 1372 + 1373 + let did_response = crate::app::app(state.clone()) 1374 + .oneshot(request) 1375 + .await 1376 + .unwrap(); 1377 + assert_eq!( 1378 + did_response.status(), 1379 + StatusCode::OK, 1380 + "DID ceremony should succeed" 1381 + ); 1382 + let body_bytes = axum::body::to_bytes(did_response.into_body(), usize::MAX) 1383 + .await 1384 + .unwrap(); 1385 + let did_body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 1386 + let did = did_body["did"].as_str().unwrap().to_string(); 1387 + 1388 + // password_hash should be a non-NULL argon2id PHC string. 1389 + let stored_hash: Option<String> = 1390 + sqlx::query_scalar("SELECT password_hash FROM accounts WHERE did = ?") 1391 + .bind(&did) 1392 + .fetch_one(&db) 1393 + .await 1394 + .unwrap(); 1395 + let hash_str = stored_hash.expect("password_hash should not be NULL when password provided"); 1396 + assert!( 1397 + hash_str.starts_with("$argon2id$"), 1398 + "password_hash should be an argon2id PHC string, got: {hash_str}" 1399 + ); 1400 + 1401 + // createSession with the correct password should return 200. 1402 + let cs_request = Request::builder() 1403 + .method("POST") 1404 + .uri("/xrpc/com.atproto.server.createSession") 1405 + .header("Content-Type", "application/json") 1406 + .body(Body::from(format!( 1407 + r#"{{"identifier":"{did}","password":"mysecretpassword"}}"# 1408 + ))) 1409 + .unwrap(); 1410 + let cs_response = crate::app::app(state) 1411 + .oneshot(cs_request) 1412 + .await 1413 + .unwrap(); 1414 + assert_eq!( 1415 + cs_response.status(), 1416 + StatusCode::OK, 1417 + "createSession should return 200 after password-provisioned DID ceremony" 1418 + ); 1284 1419 } 1285 1420 1286 1421 // ── plc.directory error ───────────────────────────────────────────────────