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.

feat(device-key): add device_key module with simulator/macOS software path and tests

authored by

Malpercio and committed by
Tangled
f3b03d88 96c94981

+206
+2
apps/identity-wallet/src-tauri/Cargo.toml
··· 24 24 security-framework = "3" 25 25 thiserror = { workspace = true } 26 26 crypto = { workspace = true } 27 + p256 = { workspace = true } 28 + multibase = { workspace = true } 27 29 28 30 [build-dependencies] 29 31 # Tauri-specific — declared locally
+203
apps/identity-wallet/src-tauri/src/device_key.rs
··· 1 + use serde::Serialize; 2 + 3 + // ── Public types ────────────────────────────────────────────────────────────── 4 + 5 + #[derive(Debug, Serialize)] 6 + pub struct DevicePublicKey { 7 + /// Multibase base58btc-encoded compressed P-256 public key point. 8 + /// Format: 'z' + base58btc(33-byte SEC1 compressed point). 9 + pub multibase: String, 10 + /// Full did:key URI. Format: "did:key:z...". 11 + pub key_id: String, 12 + } 13 + 14 + /// Errors returned by device key operations. 15 + /// 16 + /// Serializes as `{ "code": "SCREAMING_SNAKE_CASE" }` — matches the 17 + /// `CreateAccountError` pattern in `lib.rs`. 18 + #[derive(Debug, Serialize, thiserror::Error)] 19 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 20 + pub enum DeviceKeyError { 21 + #[error("key generation failed")] 22 + KeyGenerationFailed, 23 + #[error("key not found; call get_or_create before sign")] 24 + KeyNotFound, 25 + #[error("signing failed")] 26 + SigningFailed, 27 + /// DER → r||s parse failed (SE path only; not reachable on simulator). 28 + #[error("invalid signature encoding")] 29 + InvalidSignature, 30 + #[error("keychain error: {message}")] 31 + KeychainError { message: String }, 32 + } 33 + 34 + // ── Simulator / macOS host path ─────────────────────────────────────────────── 35 + // 36 + // Covers: 37 + // - macOS (target_os = "macos"): used for `cargo test` on developer machines 38 + // - iOS Simulator (target_os = "ios", target_env = "sim"): no Secure Enclave hardware 39 + // 40 + // Note: the design doc cfg (all(target_vendor = "apple", target_env = "sim")) does not 41 + // match macOS host where target_env = "". We extend to include target_os = "macos" so 42 + // that `cargo test` exercises the software path rather than the SE stubs below. 43 + 44 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 45 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 46 + use p256::ecdsa::SigningKey; 47 + 48 + const ACCOUNT: &str = "device-rotation-key-priv"; 49 + 50 + // Try to load existing private key bytes from Keychain. 51 + let private_bytes: Vec<u8> = match crate::keychain::get_item(ACCOUNT) { 52 + Ok(bytes) => bytes, 53 + Err(_) => { 54 + // No key yet — generate a new P-256 keypair via the crypto crate. 55 + let keypair = crypto::generate_p256_keypair() 56 + .map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 57 + // Deref Zeroizing<[u8; 32]> to [u8; 32], then collect as Vec<u8>. 58 + let bytes = keypair.private_key_bytes.to_vec(); 59 + crate::keychain::store_item(ACCOUNT, &bytes) 60 + .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?; 61 + bytes 62 + } 63 + }; 64 + 65 + // Reconstruct the public key from stored private bytes. 66 + let signing_key = SigningKey::from_slice(&private_bytes) 67 + .map_err(|_| DeviceKeyError::KeychainError { message: "invalid stored key bytes".into() })?; 68 + let encoded = signing_key.verifying_key().to_encoded_point(true); // compressed (33 bytes) 69 + let compressed = encoded.as_bytes(); 70 + let multibase = multibase::encode(multibase::Base::Base58Btc, compressed); 71 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128) 72 + // prepended to the compressed point. This matches crates/crypto/src/keys.rs 73 + // `P256_MULTICODEC_PREFIX = &[0x80, 0x24]`, which is `pub(crate)` and cannot be 74 + // imported across crate boundaries — the constant is duplicated intentionally. 75 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 76 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 77 + multikey.extend_from_slice(P256_MULTICODEC); 78 + multikey.extend_from_slice(compressed); 79 + let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey)); 80 + 81 + Ok(DevicePublicKey { multibase, key_id }) 82 + } 83 + 84 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 85 + pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 86 + use p256::ecdsa::{Signature, SigningKey}; 87 + use p256::ecdsa::signature::Signer; 88 + 89 + const ACCOUNT: &str = "device-rotation-key-priv"; 90 + 91 + // If the key doesn't exist, signal that get_or_create must be called first. 92 + let private_bytes = crate::keychain::get_item(ACCOUNT) 93 + .map_err(|_| DeviceKeyError::KeyNotFound)?; 94 + 95 + let signing_key = SigningKey::from_slice(&private_bytes) 96 + .map_err(|_| DeviceKeyError::SigningFailed)?; 97 + 98 + // sign() uses the deterministic Signer impl (RFC 6979 nonce). 99 + // It internally hashes `data` with SHA-256 before signing. 100 + let signature: Signature = signing_key.sign(data); 101 + 102 + // to_bytes() returns a fixed 64-byte GenericArray<u8, U64> (raw r||s). 103 + Ok(signature.to_bytes().to_vec()) 104 + } 105 + 106 + // ── Real device (Secure Enclave) stubs ─────────────────────────────────────── 107 + // 108 + // Phase 1 placeholder. The SE path is implemented in Phase 2. 109 + // These compile for `cargo build --target aarch64-apple-ios` but always error. 110 + 111 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 112 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 113 + Err(DeviceKeyError::KeyGenerationFailed) 114 + } 115 + 116 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 117 + pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 118 + Err(DeviceKeyError::KeyGenerationFailed) 119 + } 120 + 121 + #[cfg(test)] 122 + mod tests { 123 + use super::*; 124 + 125 + // Tests use the real macOS Keychain under service "ezpds-identity-wallet". 126 + // Run with `cargo test -- --test-threads=1` to prevent Keychain races between tests. 127 + 128 + // AC1.1 — multibase starts with 'z' and decodes to 33 bytes 129 + #[test] 130 + fn get_or_create_returns_valid_multibase() { 131 + let result = get_or_create().expect("get_or_create should succeed"); 132 + assert!(result.multibase.starts_with('z'), "multibase must start with 'z'"); 133 + let (_, decoded) = multibase::decode(&result.multibase).expect("multibase must decode"); 134 + assert_eq!(decoded.len(), 33, "compressed P-256 point must be 33 bytes"); 135 + } 136 + 137 + // AC1.2 — two successive calls are idempotent 138 + #[test] 139 + fn get_or_create_is_idempotent() { 140 + let first = get_or_create().expect("first call should succeed"); 141 + let second = get_or_create().expect("second call should succeed"); 142 + assert_eq!(first.multibase, second.multibase, "multibase must be stable"); 143 + assert_eq!(first.key_id, second.key_id, "key_id must be stable"); 144 + } 145 + 146 + // AC1.3 — key_id starts with "did:key:z" 147 + #[test] 148 + fn key_id_has_did_key_prefix() { 149 + let result = get_or_create().expect("get_or_create should succeed"); 150 + assert!( 151 + result.key_id.starts_with("did:key:z"), 152 + "key_id must start with 'did:key:z', got: {}", 153 + result.key_id 154 + ); 155 + } 156 + 157 + // AC3.1 — sign returns exactly 64 bytes 158 + #[test] 159 + fn sign_returns_64_bytes() { 160 + get_or_create().expect("must have key before signing"); 161 + let sig = sign(b"test payload").expect("sign should succeed"); 162 + assert_eq!(sig.len(), 64, "raw r||s signature must be 64 bytes"); 163 + } 164 + 165 + // AC3.2 — signing is deterministic (RFC 6979) 166 + #[test] 167 + fn sign_is_deterministic() { 168 + get_or_create().expect("must have key before signing"); 169 + let sig1 = sign(b"determinism test").expect("first sign should succeed"); 170 + let sig2 = sign(b"determinism test").expect("second sign should succeed"); 171 + assert_eq!(sig1, sig2, "same data with same key must produce same signature"); 172 + } 173 + 174 + // AC3.3 — sign before get_or_create returns KeyNotFound 175 + #[test] 176 + fn sign_before_generate_returns_key_not_found() { 177 + // Delete any key left by previous tests to simulate a fresh state. 178 + let _ = crate::keychain::delete_item("device-rotation-key-priv"); 179 + let result = sign(b"should fail"); 180 + assert!( 181 + matches!(result, Err(DeviceKeyError::KeyNotFound)), 182 + "expected KeyNotFound, got: {:?}", 183 + result 184 + ); 185 + } 186 + 187 + // AC4.1 — DeviceKeyError variants serialize as { "code": "SCREAMING_SNAKE_CASE" } 188 + #[test] 189 + fn device_key_error_serializes_as_code() { 190 + let err = DeviceKeyError::KeyGenerationFailed; 191 + let json = serde_json::to_value(&err).unwrap(); 192 + assert_eq!(json["code"], "KEY_GENERATION_FAILED"); 193 + 194 + let err2 = DeviceKeyError::KeyNotFound; 195 + let json2 = serde_json::to_value(&err2).unwrap(); 196 + assert_eq!(json2["code"], "KEY_NOT_FOUND"); 197 + 198 + let err3 = DeviceKeyError::KeychainError { message: "os error".into() }; 199 + let json3 = serde_json::to_value(&err3).unwrap(); 200 + assert_eq!(json3["code"], "KEYCHAIN_ERROR"); 201 + assert_eq!(json3["message"], "os error"); 202 + } 203 + }
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 1 1 pub mod http; 2 2 pub mod keychain; 3 + pub mod device_key; 3 4 4 5 use crypto::generate_p256_keypair; 5 6 use serde::{Deserialize, Serialize};