commits
Critical fixes:
- C1: Remove crypto error detail from client response (opaques security oracle)
Changed: 'invalid signed genesis op: {e}' → 'signed genesis op is invalid'
Server-side logging still captures full error detail
Important fixes:
- I1: Replace unwrap_or_default() on service_endpoint with proper error handling
Prevents silent DID document with empty serviceEndpoint
Returns 500 if service endpoint is missing in verified op
- I2: Handle UNIQUE constraint violation on INSERT accounts as 409 not 500
Added is_unique_violation() helper to detect constraint violations
Returns 409 DID_ALREADY_EXISTS instead of 500 INTERNAL_ERROR
- I3: Check rows_affected() on UPDATE pending_accounts SET pending_did
Detects if pending_accounts row vanished during pre-store phase
Returns error if zero rows affected (race condition detection)
- I4: Add explicit emptiness checks for rotation_keys and also_known_as arrays
Checks array is non-empty BEFORE calling first()
Returns specific error for empty arrays vs. element mismatch
Test coverage:
- G2: Add test for retry with mismatched pending_did (tampered retry)
Verifies that DID mismatch returns 500 INTERNAL_ERROR
- G3: Add device row deletion assertion to happy_path test
Verifies devices table cleanup during account promotion
- G4: Add test for malformed rotationKeyPublic format
Verifies format validation (must start with 'did:key:z')
Returns 400 INVALID_CLAIM with valid session token
Note: G5 (expired session coverage) already exists in auth.rs
(pending_session_expired_session_returns_401 test at line 321)
All tests pass: 274 total tests
No clippy warnings, cargo fmt clean
Critical:
- [C2] Validate prev=null and op_type="plc_operation" in verify_genesis_op
immediately after parsing, rejecting rotation ops and non-genesis types.
Prevents clients from submitting rotation ops that bypass plc.directory
validation, which would leave accounts stuck.
Important:
- [I5] Document rotation_key caller obligation in verify_genesis_op docstring:
"The caller is responsible for verifying that the provided key appears in
the op's rotationKeys array; this function only checks that the signature
was made by that key."
Type Design:
- [T1] Apply #[non_exhaustive] to VerifiedGenesisOp to prevent struct-literal
construction outside plc.rs module. Ensures construction only via
verify_genesis_op.
- [T2] Apply #[non_exhaustive] to PlcGenesisOp to prevent struct-literal
construction outside plc.rs module. Ensures construction only via
build_did_plc_genesis_op.
- [T3] Promote P256_MULTICODEC_PREFIX to pub(crate) in keys.rs and import
in plc.rs. Eliminates silent divergence risk between two copies of the
same constant.
- [T4] Improve rotation_key parameter docstring in verify_genesis_op to
clarify its purpose: "The key that must have signed the unsigned operation
— the caller determines which of the op's rotation keys to verify against."
Tests:
- [G1] Add verify_rotation_op_with_non_null_prev_returns_error test
covering prev != null rejection.
- [G1] Add verify_non_genesis_op_type_returns_error test covering non-
"plc_operation" type rejection.
- [G1] Add verify_rotation_key_can_verify_own_op test for canonical usage
pattern where the same keypair both signs and appears at rotationKeys[0].
This was missing coverage — prior tests verified with signing_key only.
Add verify_genesis_op and VerifiedGenesisOp to crypto CLAUDE.md
contracts. Update root CLAUDE.md crypto description to mention
verification.
- Fixed alignment of trailing comments in function call arguments
- Normalized spacing after commas (removed excessive space for alignment)
- Reformatted multi-line assert! macro calls for consistency
- Improved line wrapping for method chains and struct literals
All formatting violations (approx. 18) in the test module have been resolved.
Replaces entire #[cfg(test)] mod tests block in crates/relay/src/routes/create_did.rs.
Changes:
- Remove TEST_MASTER_KEY constant (not needed for device-signed ceremony)
- Remove relay_signing_key insertion from insert_test_data
- Add make_signed_op() helper using crypto::build_did_plc_genesis_op
- Simplify TestSetup struct (remove signing_key_id, rotation_key_id)
- Simplify test_state_for_did (no signing_key_master_key manipulation)
- Update create_did_request to use new MM-90 request shape (rotationKeyPublic, signedCreationOp)
Replaces 7 MM-89 test functions with 9 MM-90 test functions:
✓ happy_path_promotes_account_and_returns_did (AC2.1/2.2/2.3/2.4/2.5/4.1/4.2/4.3)
✓ retry_with_pending_did_skips_plc_directory (AC2.6)
✓ invalid_signature_returns_400 (AC3.1)
✓ wrong_handle_in_op_returns_400 (AC3.2)
✓ wrong_service_endpoint_returns_400 (AC3.3)
✓ wrong_rotation_key_in_op_returns_400 (AC3.4)
✓ already_promoted_account_returns_409 (AC3.5)
✓ missing_auth_returns_401 (AC3.6)
✓ plc_directory_error_returns_502 (AC3.7)
All 185 relay tests pass. All workspace tests pass. Clippy: 0 warnings.
Device-signing model replaces relay-signing. Key decisions:
- verify_genesis_op() returns typed VerifiedGenesisOp (signature + DID derivation in one pure fn)
- Strict semantic validation: rotationKeys[0], alsoKnownAs, services endpoint
- In-house CBOR (ciborium) for byte-level consistency with MM-89 signing
- Relay key absent from genesis op; added later via key rotation
- 2 implementation phases: crypto crate, then relay route replacement
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
V003 and V006 migrations have dedicated PRAGMA table_info tests verifying column
presence and types. V008 (nullable password_hash on accounts, pending_did column
on pending_accounts) had none.
Added four new tests:
1. v008_accounts_password_hash_is_nullable - Verify password_hash has notnull=0
2. v008_pending_accounts_has_pending_did_column - Verify pending_did column exists
3. v008_accounts_can_insert_null_password_hash - Test nullable behavior
4. v008_pending_accounts_pending_did_nullable_and_updatable - Test NULL and UPDATE
All tests use PRAGMA table_info to examine schema and verify migration correctness.
reqwest::Client::new() creates a client with no timeout. A hung plc.directory
connection never errors and holds pending_did pre-stored but unregistered forever.
Replace Client::new() with Client::builder().timeout(Duration::from_secs(10)).build()
in:
- main.rs: Production HTTP client initialization
- app.rs: test_state_with_plc_url test helper
- create_did.rs: test_state_for_did test helper
10 second timeout is reasonable for most PLC directory operations.
require_admin_token uses inspect_err to log non-UTF-8 header encoding issues,
but require_pending_session silently dropped such errors with no logging.
Applied the same pattern from require_admin_token to require_pending_session
to ensure consistent behavior across both auth functions.
- Critical #1: Retry path ignores pre-stored pending_did
Added comparison between derived DID and pre-stored DID on retry path.
If they don't match, return InternalError explaining the mismatch.
This prevents undetected DID mismatches when client inputs change between attempts.
- Critical #2: PLC directory response body never logged
After checking !response.status().is_success(), now consume the response body
with response.text().await and include it in the tracing::error! log.
Operators will now see the actual error response instead of just the HTTP status.
- Updated retry test to pre-store the actually-derived DID so it matches
what the handler will re-derive on the retry path.
- [Critical] Fix formatting in auth.rs (lines 91-128) and create_did.rs
(multiple locations): run cargo fmt --all to auto-format both files
to comply with CI gate requirements.
- [Minor] Remove unnecessary #[allow(dead_code)] from AppState.http_client
field in app.rs line 81. The field is actively used by create_did_handler
at create_did.rs:161 for plc.directory HTTP calls.
Completed brainstorming session. Design includes:
- crypto crate: pure build_did_plc_genesis_op function (CBOR, ECDSA P-256, RFC 6979, base32 DID derivation)
- relay crate: POST /v1/dids with pending_session auth, pre-store retry resilience, atomic account promotion
- 2 implementation phases
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
- Add oversized_public_key_returns_400 test (boundary test for devicePublicKey, mirrors register_device.rs analogue)
- Add empty_email_returns_400 test (present-but-empty email returns 400, not 422)
- Document V007 pending_sessions migration in crates/relay/src/db/CLAUDE.md
Combined mobile account creation endpoint for the iOS identity wallet
onboarding flow. Atomically redeems a claim code, creates a pending
account, registers the device, and issues a pending session token in a
single transaction — with full rollback on any step failure.
- V007 migration: pending_sessions table (token_hash UNIQUE, FKs to
pending_accounts and devices) for pre-DID session tokens
- ClaimCodeRedeemed ErrorCode (409) to distinguish already-redeemed
codes from invalid/expired ones (404) per spec
- validate_handle and is_valid_platform promoted to pub(crate) for reuse
- Bruno collection entry for the new route
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
Device registration via claim code: validates and redeems a single-use claim
code, stores the device public key, generates an opaque device_token (stored
as SHA-256 hash, returned once), and enforces platform validation.
V006 migration rebuilds the devices table to reference pending_accounts.id
instead of accounts.did (registration precedes DID assignment), adding
platform, public_key, and device_token_hash columns. sessions, oauth_tokens,
and refresh_tokens are also rebuilt to maintain correct FK targets after the
cascading rename.
Critical:
- Run cargo fmt --all (formatting violations in auth.rs, create_account.rs)
- Add unit tests for require_admin_token() in auth.rs (6 tests covering all
branches including the non-UTF-8 Authorization header path)
- Add unit tests for generate_code() in code_gen.rs (4 tests: length, charset,
character set membership, non-constant output)
Important:
- Narrow pub mod auth to pub(crate) mod auth in routes/mod.rs
- Drop pub from CODE_LEN and CHARSET in code_gen.rs (no external consumers)
- Switch OR EXISTS queries from bool to i64 + CAST AS INTEGER to avoid
sqlx type-affinity ambiguity on untyped SQLite expressions
- Narrow auth.rs doc comment: presence/prefix checks are conventional
short-circuits; only the final comparison uses subtle::ct_eq
- Remove stale "handle_in_handles query coverage" comment from test
- Log constraint name in unique_violation_source default arm so unexpected
future constraints are visible in traces
Suggestions (high-value):
- Use bool::from(ct_eq(...)) instead of unwrap_u8() != 1 per subtle docs
- Upgrade non-UTF-8 Authorization header log from debug to warn
Extract shared admin Bearer token validation, code generation, and test
helpers that were duplicated across claim_codes, create_account, and
create_signing_key.
- routes/auth.rs: require_admin_token() replaces 37-line auth block copied 3×;
also fixes create_signing_key which was missing the inspect_err debug log
for non-UTF-8 Authorization headers
- routes/code_gen.rs: generate_code() + CODE_LEN/CHARSET moved here from
claim_codes and create_account where they were defined identically
- routes/test_utils.rs: test_state_with_admin_token() shared instead of
duplicated in each route's test module
- create_account: consolidate 4 separate pre-check queries into 2 OR EXISTS
queries (email and handle each check both tables in one round-trip)
Critical fixes:
- Distinguish unique constraint violations by inspecting db_err.message():
pending_accounts.email → AccountExists (409), pending_accounts.handle
→ HandleTaken (409), claim_codes.code → retry. Prevents TOCTOU races
from being silently swallowed and retried as claim-code collisions.
- Add AccountExists, HandleTaken, InvalidHandle to status_code_mapping test
Important fixes:
- Add duplicate_handle_in_handles_returns_409 test covering the
handle_in_handles SELECT path (was untested)
- Assert json["error"]["code"] in all four 409 conflict tests so
AccountExists vs HandleTaken body swaps are caught
- Add inspect_err logging to both execute calls inside
insert_pending_account for per-operation failure attribution
Suggestion:
- Replace retry-exhaustion message "failed to generate unique claim code
after retries" with generic "failed to create account"; move the
detail to a tracing::error! before the return
Adds operator-authenticated account provisioning endpoint that creates
a pending account slot with a 24h claim code before DID assignment.
- V005 migration: pending_accounts staging table (id, email, handle,
tier, claim_code FK → claim_codes, created_at); unique indices on
email and handle
- New ErrorCode variants: AccountExists (409), HandleTaken (409),
InvalidHandle (400)
- POST /v1/accounts handler: auth → handle validation → email/handle
uniqueness across both pending and active tables → single-TX insert
into claim_codes + pending_accounts → 201 with {accountId, did: null,
claimCode, status: "pending"}
- 26 tests covering happy path, DB persistence, duplicate email/handle,
handle format, tier validation, missing fields, auth, and 500 path
- Bruno create_account.bru collection entry
- uuid v1 workspace dependency for account_id generation
Adds a Bruno HTTP client collection covering all four relay endpoints
(health, describeServer, claim-codes, create-signing-key) with a local
environment template and a mandatory update rule in CLAUDE.md.
- Remove `&& attempt < 2` guard: unique violations now always retry;
post-loop error becomes the exhaustion case (was dead code before)
- Fix comment: "Retry up to 3 times" → "Attempt up to 3 times total (2 retries)"
- Add expires_at window assertion to persistence tests (5s tolerance)
- Add non_unique_db_error_returns_500_without_retry test (closes pool before request)
- Annotate begin/commit in insert_claim_codes with inspect_err logging
- Log non-UTF-8 Authorization header at debug level
- Add doc comment to ClaimCodesResponse.codes field
Adds operator-authenticated endpoint for generating batch invite codes
before account creation exists. Fixes the Wave 1 schema which incorrectly
required a NOT NULL DID FK on claim_codes, making pre-account invite codes
structurally impossible.
- V004 migration: recreates claim_codes without did FK; adds expires_at index
- POST /v1/accounts/claim-codes: Bearer-auth, count 1–10, configurable expiry
- 6-char uppercase alphanumeric codes via OsRng, batch-inserted in one tx
- Status derived from redeemed_at/expires_at columns (no status enum)
- 15 handler tests covering happy path, format, persistence, validation, auth
It is the random coefficient (coeffs[i]), not the secret byte directly,
that passes through gf_mul. The secret byte only goes through gf_add
(XOR, inherently branchless). Security intent unchanged.
- Replace branching GF(2^8) reduction with branchless mask:
(a as i8 >> 7) as u8 selects 0x1b without branching on secret bits
- Add upper-bound index check (> 3) in combine_shares; silent wrong
reconstruction on out-of-range indices was not caught before
- Switch fill_bytes -> try_fill_bytes so RNG failure returns
CryptoError::SecretSharing instead of panicking
- Remove #[derive(Clone)] from ShamirShare — no call site uses it and
Clone on a secret-bearing type is inconsistent with P256Keypair
- Expand combine_with_index_zero_fails to test both argument positions
- Add combine_with_index_out_of_range_fails test (index: 4)
- Expand gf_mul_is_commutative to exhaustive 256×256 check
- Update gf_mul/gf_inv doc comments: describe branchless reduction,
fix "repeated squaring" -> "binary exponentiation (square-and-multiply)",
add standard -> GF(2^8) Lagrange derivation step
Adds split_secret and combine_shares to crates/crypto using GF(2^8)
arithmetic (AES irreducible polynomial 0x11b). Any 2 of the 3 returned
shares reconstruct the original 32-byte secret; a single share reveals
nothing (information-theoretic security). Share data is zeroized on drop.
Closes MM-93
- Fix 1: Replace wrong sqlx error source for corrupt migration version. Use
Protocol(format!(...)) instead of RowNotFound to accurately describe the
i64-to-u32 conversion failure.
- Fix 2: Remove InvalidKeyId from CryptoError variants list in CLAUDE.md (never
implemented).
- Fix 3: Rename three test functions to match their assertions:
unsupported_algorithm_returns_422, empty_algorithm_returns_422,
missing_algorithm_field_returns_422.
- Fix 4: Add test for null algorithm field. Null deserialization returns 400
(Bad Request) from Axum's default JSON rejection, distinct from missing/invalid
enum variants (422).
- Fix 5: Add key_id context to DB insert error log for better debugging when
signing key persistence fails.
- Fix 6: Add comment explaining why Sensitive<T> has pub T field — deliberate
design choice to make raw value access visible in source for code review.
- Fix #5: Config.signing_key_master_key leaks via Debug and clone
- Wrap signing_key_master_key in Sensitive<Zeroizing<[u8; 32]>>
- Adds Sensitive newtype that redacts Debug output to "***"
- Zeroizing ensures key bytes are securely zeroized on drop
- Never copies key into non-zeroizing allocation
- Fix #6: CreateSigningKeyRequest.algorithm should be one-variant enum
- Replace Option<String> with Algorithm enum (single P256 variant)
- Serde validates at deserialization time, not runtime
- Remove dead runtime algorithm matching code
- Updated tests to expect 422 (Unprocessable Entity) for invalid enum
- Fix #7: Remove dead CryptoError::InvalidKeyId variant
- Variant was never constructed in this PR
- Fix #8: Wrap raw_bytes in Zeroizing in keys.rs
- Ensures intermediate GenericArray from secret_key.to_bytes() is zeroized
- Guards against future changes to p256 library behavior
- Fix #9: Add PRAGMA table_info test for V003 relay_signing_keys columns
- Validates exact column order and names: id, algorithm, public_key,
private_key_encrypted, created_at
- Fix #10: Add V003 PRIMARY KEY uniqueness constraint test
- Verifies duplicate id inserts fail with constraint violation
- Fix #11: Introduce DidKeyUri newtype for P256Keypair.key_id
- Prevents silent positional swap bugs in SQL binds and API responses
- Type-safe distinction between key_id (did:key:z...) and public_key (z...)
- Converts to string for DB inserts and JSON responses
Changes:
- crates/common: Add zeroize dependency, Sensitive<T> wrapper, export it
- crates/crypto: Add DidKeyUri newtype, remove InvalidKeyId CryptoError variant
- crates/relay: Add zeroize dependency, update handler to use new types, add V003 tests
- Issue #1 (Critical): Replace non-constant-time Bearer token comparison with subtle::ConstantTimeEq to prevent timing attacks in create_signing_key.rs:57
- Issue #2 (Critical): Move zeroize and subtle dependencies to [workspace.dependencies] in root Cargo.toml; update crates to use { workspace = true } per project conventions
- Issue #3 (High): Fix migration infrastructure to return DbError instead of silently mapping corrupt schema_migrations version numbers to 0; now propagates parse errors with ? operator in mod.rs:99-107
- Issue #4 (High): Add sentinel field signing_key_master_key_toml_sentinel to RawConfig to detect and reject misconfigured operators who set the security-sensitive field in relay.toml instead of env var EZPDS_SIGNING_KEY_MASTER_KEY; includes validation check and regression test in config.rs
Add crypto crate CLAUDE.md (new public API: P-256 keygen, AES-256-GCM
encrypt/decrypt, did:key derivation). Update db CLAUDE.md with V003
migration. Update root CLAUDE.md crypto crate description.
Critical fixes:
- C1: Remove crypto error detail from client response (opaques security oracle)
Changed: 'invalid signed genesis op: {e}' → 'signed genesis op is invalid'
Server-side logging still captures full error detail
Important fixes:
- I1: Replace unwrap_or_default() on service_endpoint with proper error handling
Prevents silent DID document with empty serviceEndpoint
Returns 500 if service endpoint is missing in verified op
- I2: Handle UNIQUE constraint violation on INSERT accounts as 409 not 500
Added is_unique_violation() helper to detect constraint violations
Returns 409 DID_ALREADY_EXISTS instead of 500 INTERNAL_ERROR
- I3: Check rows_affected() on UPDATE pending_accounts SET pending_did
Detects if pending_accounts row vanished during pre-store phase
Returns error if zero rows affected (race condition detection)
- I4: Add explicit emptiness checks for rotation_keys and also_known_as arrays
Checks array is non-empty BEFORE calling first()
Returns specific error for empty arrays vs. element mismatch
Test coverage:
- G2: Add test for retry with mismatched pending_did (tampered retry)
Verifies that DID mismatch returns 500 INTERNAL_ERROR
- G3: Add device row deletion assertion to happy_path test
Verifies devices table cleanup during account promotion
- G4: Add test for malformed rotationKeyPublic format
Verifies format validation (must start with 'did:key:z')
Returns 400 INVALID_CLAIM with valid session token
Note: G5 (expired session coverage) already exists in auth.rs
(pending_session_expired_session_returns_401 test at line 321)
All tests pass: 274 total tests
No clippy warnings, cargo fmt clean
Critical:
- [C2] Validate prev=null and op_type="plc_operation" in verify_genesis_op
immediately after parsing, rejecting rotation ops and non-genesis types.
Prevents clients from submitting rotation ops that bypass plc.directory
validation, which would leave accounts stuck.
Important:
- [I5] Document rotation_key caller obligation in verify_genesis_op docstring:
"The caller is responsible for verifying that the provided key appears in
the op's rotationKeys array; this function only checks that the signature
was made by that key."
Type Design:
- [T1] Apply #[non_exhaustive] to VerifiedGenesisOp to prevent struct-literal
construction outside plc.rs module. Ensures construction only via
verify_genesis_op.
- [T2] Apply #[non_exhaustive] to PlcGenesisOp to prevent struct-literal
construction outside plc.rs module. Ensures construction only via
build_did_plc_genesis_op.
- [T3] Promote P256_MULTICODEC_PREFIX to pub(crate) in keys.rs and import
in plc.rs. Eliminates silent divergence risk between two copies of the
same constant.
- [T4] Improve rotation_key parameter docstring in verify_genesis_op to
clarify its purpose: "The key that must have signed the unsigned operation
— the caller determines which of the op's rotation keys to verify against."
Tests:
- [G1] Add verify_rotation_op_with_non_null_prev_returns_error test
covering prev != null rejection.
- [G1] Add verify_non_genesis_op_type_returns_error test covering non-
"plc_operation" type rejection.
- [G1] Add verify_rotation_key_can_verify_own_op test for canonical usage
pattern where the same keypair both signs and appears at rotationKeys[0].
This was missing coverage — prior tests verified with signing_key only.
- Fixed alignment of trailing comments in function call arguments
- Normalized spacing after commas (removed excessive space for alignment)
- Reformatted multi-line assert! macro calls for consistency
- Improved line wrapping for method chains and struct literals
All formatting violations (approx. 18) in the test module have been resolved.
Replaces entire #[cfg(test)] mod tests block in crates/relay/src/routes/create_did.rs.
Changes:
- Remove TEST_MASTER_KEY constant (not needed for device-signed ceremony)
- Remove relay_signing_key insertion from insert_test_data
- Add make_signed_op() helper using crypto::build_did_plc_genesis_op
- Simplify TestSetup struct (remove signing_key_id, rotation_key_id)
- Simplify test_state_for_did (no signing_key_master_key manipulation)
- Update create_did_request to use new MM-90 request shape (rotationKeyPublic, signedCreationOp)
Replaces 7 MM-89 test functions with 9 MM-90 test functions:
✓ happy_path_promotes_account_and_returns_did (AC2.1/2.2/2.3/2.4/2.5/4.1/4.2/4.3)
✓ retry_with_pending_did_skips_plc_directory (AC2.6)
✓ invalid_signature_returns_400 (AC3.1)
✓ wrong_handle_in_op_returns_400 (AC3.2)
✓ wrong_service_endpoint_returns_400 (AC3.3)
✓ wrong_rotation_key_in_op_returns_400 (AC3.4)
✓ already_promoted_account_returns_409 (AC3.5)
✓ missing_auth_returns_401 (AC3.6)
✓ plc_directory_error_returns_502 (AC3.7)
All 185 relay tests pass. All workspace tests pass. Clippy: 0 warnings.
Device-signing model replaces relay-signing. Key decisions:
- verify_genesis_op() returns typed VerifiedGenesisOp (signature + DID derivation in one pure fn)
- Strict semantic validation: rotationKeys[0], alsoKnownAs, services endpoint
- In-house CBOR (ciborium) for byte-level consistency with MM-89 signing
- Relay key absent from genesis op; added later via key rotation
- 2 implementation phases: crypto crate, then relay route replacement
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
V003 and V006 migrations have dedicated PRAGMA table_info tests verifying column
presence and types. V008 (nullable password_hash on accounts, pending_did column
on pending_accounts) had none.
Added four new tests:
1. v008_accounts_password_hash_is_nullable - Verify password_hash has notnull=0
2. v008_pending_accounts_has_pending_did_column - Verify pending_did column exists
3. v008_accounts_can_insert_null_password_hash - Test nullable behavior
4. v008_pending_accounts_pending_did_nullable_and_updatable - Test NULL and UPDATE
All tests use PRAGMA table_info to examine schema and verify migration correctness.
reqwest::Client::new() creates a client with no timeout. A hung plc.directory
connection never errors and holds pending_did pre-stored but unregistered forever.
Replace Client::new() with Client::builder().timeout(Duration::from_secs(10)).build()
in:
- main.rs: Production HTTP client initialization
- app.rs: test_state_with_plc_url test helper
- create_did.rs: test_state_for_did test helper
10 second timeout is reasonable for most PLC directory operations.
- Critical #1: Retry path ignores pre-stored pending_did
Added comparison between derived DID and pre-stored DID on retry path.
If they don't match, return InternalError explaining the mismatch.
This prevents undetected DID mismatches when client inputs change between attempts.
- Critical #2: PLC directory response body never logged
After checking !response.status().is_success(), now consume the response body
with response.text().await and include it in the tracing::error! log.
Operators will now see the actual error response instead of just the HTTP status.
- Updated retry test to pre-store the actually-derived DID so it matches
what the handler will re-derive on the retry path.
- [Critical] Fix formatting in auth.rs (lines 91-128) and create_did.rs
(multiple locations): run cargo fmt --all to auto-format both files
to comply with CI gate requirements.
- [Minor] Remove unnecessary #[allow(dead_code)] from AppState.http_client
field in app.rs line 81. The field is actively used by create_did_handler
at create_did.rs:161 for plc.directory HTTP calls.
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
Combined mobile account creation endpoint for the iOS identity wallet
onboarding flow. Atomically redeems a claim code, creates a pending
account, registers the device, and issues a pending session token in a
single transaction — with full rollback on any step failure.
- V007 migration: pending_sessions table (token_hash UNIQUE, FKs to
pending_accounts and devices) for pre-DID session tokens
- ClaimCodeRedeemed ErrorCode (409) to distinguish already-redeemed
codes from invalid/expired ones (404) per spec
- validate_handle and is_valid_platform promoted to pub(crate) for reuse
- Bruno collection entry for the new route
Critical: correct V006 migration comment — SQLite does not auto-update FK
references in child tables on RENAME; the migration is safe because all
tables are empty (no DML-time FK checks fire).
Important:
- Add UNIQUE INDEX idx_devices_token_hash on devices.device_token_hash
- Add max-length check (512 chars) on devicePublicKey input
- Add #[tracing::instrument] + claim_code field to redeem_and_register;
distinguish RowNotFound from other errors in log messages
- Fix seed_pending_account helper to generate unique codes/email/handle
per call so it is safe to invoke multiple times on the same pool
- Add orphaned_claim_code_returns_500_and_does_not_redeem_code test
(verifies atomicity: transaction rolls back if pending_accounts lookup
fails, leaving claim code unredeemed)
- Extend closed_db_pool_returns_500 and platform_is_case_sensitive tests
to assert error code in response body
- Add oversized_public_key_returns_400 test
Device registration via claim code: validates and redeems a single-use claim
code, stores the device public key, generates an opaque device_token (stored
as SHA-256 hash, returned once), and enforces platform validation.
V006 migration rebuilds the devices table to reference pending_accounts.id
instead of accounts.did (registration precedes DID assignment), adding
platform, public_key, and device_token_hash columns. sessions, oauth_tokens,
and refresh_tokens are also rebuilt to maintain correct FK targets after the
cascading rename.
Critical:
- Run cargo fmt --all (formatting violations in auth.rs, create_account.rs)
- Add unit tests for require_admin_token() in auth.rs (6 tests covering all
branches including the non-UTF-8 Authorization header path)
- Add unit tests for generate_code() in code_gen.rs (4 tests: length, charset,
character set membership, non-constant output)
Important:
- Narrow pub mod auth to pub(crate) mod auth in routes/mod.rs
- Drop pub from CODE_LEN and CHARSET in code_gen.rs (no external consumers)
- Switch OR EXISTS queries from bool to i64 + CAST AS INTEGER to avoid
sqlx type-affinity ambiguity on untyped SQLite expressions
- Narrow auth.rs doc comment: presence/prefix checks are conventional
short-circuits; only the final comparison uses subtle::ct_eq
- Remove stale "handle_in_handles query coverage" comment from test
- Log constraint name in unique_violation_source default arm so unexpected
future constraints are visible in traces
Suggestions (high-value):
- Use bool::from(ct_eq(...)) instead of unwrap_u8() != 1 per subtle docs
- Upgrade non-UTF-8 Authorization header log from debug to warn
Extract shared admin Bearer token validation, code generation, and test
helpers that were duplicated across claim_codes, create_account, and
create_signing_key.
- routes/auth.rs: require_admin_token() replaces 37-line auth block copied 3×;
also fixes create_signing_key which was missing the inspect_err debug log
for non-UTF-8 Authorization headers
- routes/code_gen.rs: generate_code() + CODE_LEN/CHARSET moved here from
claim_codes and create_account where they were defined identically
- routes/test_utils.rs: test_state_with_admin_token() shared instead of
duplicated in each route's test module
- create_account: consolidate 4 separate pre-check queries into 2 OR EXISTS
queries (email and handle each check both tables in one round-trip)
Critical fixes:
- Distinguish unique constraint violations by inspecting db_err.message():
pending_accounts.email → AccountExists (409), pending_accounts.handle
→ HandleTaken (409), claim_codes.code → retry. Prevents TOCTOU races
from being silently swallowed and retried as claim-code collisions.
- Add AccountExists, HandleTaken, InvalidHandle to status_code_mapping test
Important fixes:
- Add duplicate_handle_in_handles_returns_409 test covering the
handle_in_handles SELECT path (was untested)
- Assert json["error"]["code"] in all four 409 conflict tests so
AccountExists vs HandleTaken body swaps are caught
- Add inspect_err logging to both execute calls inside
insert_pending_account for per-operation failure attribution
Suggestion:
- Replace retry-exhaustion message "failed to generate unique claim code
after retries" with generic "failed to create account"; move the
detail to a tracing::error! before the return
Adds operator-authenticated account provisioning endpoint that creates
a pending account slot with a 24h claim code before DID assignment.
- V005 migration: pending_accounts staging table (id, email, handle,
tier, claim_code FK → claim_codes, created_at); unique indices on
email and handle
- New ErrorCode variants: AccountExists (409), HandleTaken (409),
InvalidHandle (400)
- POST /v1/accounts handler: auth → handle validation → email/handle
uniqueness across both pending and active tables → single-TX insert
into claim_codes + pending_accounts → 201 with {accountId, did: null,
claimCode, status: "pending"}
- 26 tests covering happy path, DB persistence, duplicate email/handle,
handle format, tier validation, missing fields, auth, and 500 path
- Bruno create_account.bru collection entry
- uuid v1 workspace dependency for account_id generation
- Remove `&& attempt < 2` guard: unique violations now always retry;
post-loop error becomes the exhaustion case (was dead code before)
- Fix comment: "Retry up to 3 times" → "Attempt up to 3 times total (2 retries)"
- Add expires_at window assertion to persistence tests (5s tolerance)
- Add non_unique_db_error_returns_500_without_retry test (closes pool before request)
- Annotate begin/commit in insert_claim_codes with inspect_err logging
- Log non-UTF-8 Authorization header at debug level
- Add doc comment to ClaimCodesResponse.codes field
Adds operator-authenticated endpoint for generating batch invite codes
before account creation exists. Fixes the Wave 1 schema which incorrectly
required a NOT NULL DID FK on claim_codes, making pre-account invite codes
structurally impossible.
- V004 migration: recreates claim_codes without did FK; adds expires_at index
- POST /v1/accounts/claim-codes: Bearer-auth, count 1–10, configurable expiry
- 6-char uppercase alphanumeric codes via OsRng, batch-inserted in one tx
- Status derived from redeemed_at/expires_at columns (no status enum)
- 15 handler tests covering happy path, format, persistence, validation, auth
- Replace branching GF(2^8) reduction with branchless mask:
(a as i8 >> 7) as u8 selects 0x1b without branching on secret bits
- Add upper-bound index check (> 3) in combine_shares; silent wrong
reconstruction on out-of-range indices was not caught before
- Switch fill_bytes -> try_fill_bytes so RNG failure returns
CryptoError::SecretSharing instead of panicking
- Remove #[derive(Clone)] from ShamirShare — no call site uses it and
Clone on a secret-bearing type is inconsistent with P256Keypair
- Expand combine_with_index_zero_fails to test both argument positions
- Add combine_with_index_out_of_range_fails test (index: 4)
- Expand gf_mul_is_commutative to exhaustive 256×256 check
- Update gf_mul/gf_inv doc comments: describe branchless reduction,
fix "repeated squaring" -> "binary exponentiation (square-and-multiply)",
add standard -> GF(2^8) Lagrange derivation step
- Fix 1: Replace wrong sqlx error source for corrupt migration version. Use
Protocol(format!(...)) instead of RowNotFound to accurately describe the
i64-to-u32 conversion failure.
- Fix 2: Remove InvalidKeyId from CryptoError variants list in CLAUDE.md (never
implemented).
- Fix 3: Rename three test functions to match their assertions:
unsupported_algorithm_returns_422, empty_algorithm_returns_422,
missing_algorithm_field_returns_422.
- Fix 4: Add test for null algorithm field. Null deserialization returns 400
(Bad Request) from Axum's default JSON rejection, distinct from missing/invalid
enum variants (422).
- Fix 5: Add key_id context to DB insert error log for better debugging when
signing key persistence fails.
- Fix 6: Add comment explaining why Sensitive<T> has pub T field — deliberate
design choice to make raw value access visible in source for code review.
- Fix #5: Config.signing_key_master_key leaks via Debug and clone
- Wrap signing_key_master_key in Sensitive<Zeroizing<[u8; 32]>>
- Adds Sensitive newtype that redacts Debug output to "***"
- Zeroizing ensures key bytes are securely zeroized on drop
- Never copies key into non-zeroizing allocation
- Fix #6: CreateSigningKeyRequest.algorithm should be one-variant enum
- Replace Option<String> with Algorithm enum (single P256 variant)
- Serde validates at deserialization time, not runtime
- Remove dead runtime algorithm matching code
- Updated tests to expect 422 (Unprocessable Entity) for invalid enum
- Fix #7: Remove dead CryptoError::InvalidKeyId variant
- Variant was never constructed in this PR
- Fix #8: Wrap raw_bytes in Zeroizing in keys.rs
- Ensures intermediate GenericArray from secret_key.to_bytes() is zeroized
- Guards against future changes to p256 library behavior
- Fix #9: Add PRAGMA table_info test for V003 relay_signing_keys columns
- Validates exact column order and names: id, algorithm, public_key,
private_key_encrypted, created_at
- Fix #10: Add V003 PRIMARY KEY uniqueness constraint test
- Verifies duplicate id inserts fail with constraint violation
- Fix #11: Introduce DidKeyUri newtype for P256Keypair.key_id
- Prevents silent positional swap bugs in SQL binds and API responses
- Type-safe distinction between key_id (did:key:z...) and public_key (z...)
- Converts to string for DB inserts and JSON responses
Changes:
- crates/common: Add zeroize dependency, Sensitive<T> wrapper, export it
- crates/crypto: Add DidKeyUri newtype, remove InvalidKeyId CryptoError variant
- crates/relay: Add zeroize dependency, update handler to use new types, add V003 tests
- Issue #1 (Critical): Replace non-constant-time Bearer token comparison with subtle::ConstantTimeEq to prevent timing attacks in create_signing_key.rs:57
- Issue #2 (Critical): Move zeroize and subtle dependencies to [workspace.dependencies] in root Cargo.toml; update crates to use { workspace = true } per project conventions
- Issue #3 (High): Fix migration infrastructure to return DbError instead of silently mapping corrupt schema_migrations version numbers to 0; now propagates parse errors with ? operator in mod.rs:99-107
- Issue #4 (High): Add sentinel field signing_key_master_key_toml_sentinel to RawConfig to detect and reject misconfigured operators who set the security-sensitive field in relay.toml instead of env var EZPDS_SIGNING_KEY_MASTER_KEY; includes validation check and regression test in config.rs