An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(identity-wallet): normalize ECDSA signature to low-S in simulator path

ATProto/PLC directory requires low-S ECDSA signatures and rejects high-S
signatures with "Invalid signature on op". The ecdsa 0.16 crate's sign()
does not auto-normalize to low-S — roughly half of all signatures would be
high-S and consistently rejected by the PLC directory.

The SE path already called normalize_s(), but the simulator/macOS path did
not. Added normalize_s() call and a test that verifies sign() always produces
low-S output.

authored by

Malpercio and committed by
Tangled
caf67529 41886ac4

+22 -2
+2 -2
apps/identity-wallet/CLAUDE.md
··· 43 43 - `create_account` maps relay HTTP error codes to typed `CreateAccountError` variants (EXPIRED_CODE, REDEEMED_CODE, EMAIL_TAKEN, HANDLE_TAKEN, NETWORK_ERROR, UNKNOWN) serialized as `{ code: "SCREAMING_SNAKE" }` for the frontend 44 44 - `perform_did_ceremony` maps failures to typed `DIDCeremonyError` variants (KEY_NOT_FOUND, RELAY_KEY_FETCH_FAILED, NO_RELAY_SIGNING_KEY, SIGNING_FAILED, DID_CREATION_FAILED, KEYCHAIN_ERROR, NETWORK_ERROR) serialized as `{ code: "SCREAMING_SNAKE_CASE" }` for the frontend 45 45 - `device_key::get_or_create()` is idempotent -- returns the same key on every call for a given device 46 - - `device_key::sign()` returns raw 64-byte r||s ECDSA signatures; deterministic (RFC 6979) on simulator, low-S normalized on real device 46 + - `device_key::sign()` returns raw 64-byte r||s ECDSA signatures; low-S normalized on both paths (ATProto/PLC directory requires low-S); deterministic (RFC 6979) on simulator 47 47 - `DeviceKeyError` variants serialize as `{ code: "SCREAMING_SNAKE_CASE" }` matching the `CreateAccountError` pattern 48 48 - Device key dispatch: `#[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))]` for software path, `#[cfg(all(target_os = "ios", not(target_env = "sim")))]` for Secure Enclave path 49 49 ··· 173 173 - **Device key module (`device_key.rs`) with `#[cfg]` dispatch**: Two compile-time paths share the same public API (`get_or_create`, `sign`). macOS and iOS Simulator use software P-256 via `crypto` crate with private key bytes in Keychain. Real iOS device uses Secure Enclave -- private key never leaves the SE; only the compressed public key and application_label (SE-assigned SHA1) are stored in regular Keychain for lookup. 174 174 - **Idempotent key lifecycle**: `get_or_create()` generates on first call, returns the same key on subsequent calls. `create_account` delegates to `device_key::get_or_create()` so the same device key is sent to the relay on every attempt (retries are safe). 175 175 - **P-256 multicodec prefix duplicated**: `device_key.rs` duplicates the `[0x80, 0x24]` P-256 multicodec varint prefix from `crates/crypto/src/keys.rs` because the constant is `pub(crate)` there. This is intentional -- the identity-wallet crate should not depend on internal crypto crate layout. 176 - - **Low-S normalization on SE path**: Apple's Secure Enclave may produce high-S ECDSA signatures. The SE `sign()` path applies `normalize_s()` to ensure ATProto-compatible low-S form. The simulator path uses RFC 6979 deterministic nonces which already produce low-S. 176 + - **Low-S normalization on both paths**: ATProto/PLC directory requires low-S ECDSA signatures (enforced by `@noble/curves` in strict mode). Both the SE path and the simulator path apply `normalize_s()` after signing. RFC 6979 only provides deterministic nonces — it does NOT guarantee low-S; that requires an explicit normalization step. 177 177 - **reqwest with rustls-tls**: Uses `default-features = false` + `rustls-tls` to avoid linking OpenSSL. On iOS, rustls handles TLS natively without additional system deps. 178 178 179 179 ## Invariants
+20
apps/identity-wallet/src-tauri/src/device_key.rs
··· 123 123 // It internally hashes `data` with SHA-256 before signing. 124 124 let signature: Signature = signing_key.sign(data); 125 125 126 + // Normalize to low-S form. ATProto/PLC directory requires low-S ECDSA 127 + // signatures; without this, roughly half of all signatures would be 128 + // rejected by the PLC directory even though they are mathematically valid. 129 + // normalize_s() returns Some(normalized) if s was high, None if already low. 130 + let signature = signature.normalize_s().unwrap_or(signature); 131 + 126 132 // to_bytes() returns a fixed 64-byte GenericArray<u8, U64> (raw r||s). 127 133 Ok(signature.to_bytes().to_vec()) 128 134 } ··· 411 417 let json5 = serde_json::to_value(&err5).unwrap(); 412 418 assert_eq!(json5["code"], "KEYCHAIN_ERROR"); 413 419 assert_eq!(json5["message"], "os error"); 420 + } 421 + 422 + // Signatures must be in low-S form; PLC directory (via @noble/curves) rejects high-S. 423 + // normalize_s() returns None when the signature is already low-S. 424 + #[test] 425 + fn sign_produces_low_s_signature() { 426 + use p256::ecdsa::Signature; 427 + get_or_create().expect("must have key"); 428 + let sig_bytes = sign(b"low-s test").expect("sign must succeed"); 429 + let sig = Signature::from_bytes(sig_bytes.as_slice().into()).expect("must parse sig"); 430 + assert!( 431 + sig.normalize_s().is_none(), 432 + "signature must already be in low-S form (normalize_s returns None when already low-S)" 433 + ); 414 434 } 415 435 416 436 // Ensures DevicePublicKey serializes key_id as keyId (camelCase) for Tauri IPC.