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 Secure Enclave path for real iOS device (Phase 2)

Implements MM-145.AC2: Private key material is protected via Secure Enclave.

Task 1: Add OSX_10_12 feature to security-framework (3.x) in Cargo.toml.
Enables SecKey APIs and GenerateKeyOptions required for SE key generation.

Task 2: Replace real-device stubs with full SE implementation.
- get_or_create(): Generate P-256 keypair in SE, store compressed public key
and application_label in Keychain for fast path on subsequent calls.
- sign(): Retrieve SE private key by application_label, sign with ECDSA-SHA256,
convert DER to raw r||s with low-S normalization (ATProto compliance).

Task 3: Verify and validate.
- 20 simulator tests pass (7 Phase 1 device_key + 13 lib/error tests).
- iOS build compiles for aarch64-apple-ios (SE path confirmed).
- clippy: no warnings (-D warnings).
- rustfmt: all code formatted per project conventions.

Infrastructure note: Added CC_aarch64_apple_darwin and AR_aarch64_apple_darwin
to .cargo/config.toml to handle Nix cc-wrapper incompatibilities with iOS
cross-compilation (matches existing pattern for aarch64-apple-ios targets).

AC2.1 verification (persistence across cold restart) deferred to manual
device testing per implementation plan Task 3, Step 4.
AC2.2 verified by design: SecKey::new with Token::SecureEnclave is
non-extractable; attempted external_representation() on private key returns None.

authored by

Malpercio and committed by
Tangled
c886040a 6c06c995

+154 -7
+2
Cargo.lock
··· 2350 2350 version = "0.1.0" 2351 2351 dependencies = [ 2352 2352 "crypto", 2353 + "multibase", 2354 + "p256", 2353 2355 "reqwest 0.12.28", 2354 2356 "security-framework", 2355 2357 "serde",
+2
apps/identity-wallet/src-tauri/.cargo/config.toml
··· 14 14 [env] 15 15 CC_aarch64_apple_ios_sim = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang" 16 16 CC_aarch64_apple_ios = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang" 17 + CC_aarch64_apple_darwin = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang" 17 18 AR_aarch64_apple_ios_sim = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar" 18 19 AR_aarch64_apple_ios = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar" 20 + AR_aarch64_apple_darwin = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar" 19 21 20 22 # Linker overrides — for all targets, bypass the Nix cc-wrapper linker. 21 23 #
+1 -1
apps/identity-wallet/src-tauri/Cargo.toml
··· 21 21 # reqwest with default-features = false + rustls-tls: iOS has no OpenSSL; rustls is the only 22 22 # option for TLS without adding system dependencies. default-features includes OpenSSL support. 23 23 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 24 - security-framework = "3" 24 + security-framework = { version = "3", features = ["OSX_10_12"] } 25 25 thiserror = { workspace = true } 26 26 crypto = { workspace = true } 27 27 p256 = { workspace = true }
+149 -6
apps/identity-wallet/src-tauri/src/device_key.rs
··· 1 1 use serde::Serialize; 2 2 3 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 4 + use security_framework::{ 5 + access_control::{ProtectionMode, SecAccessControl}, 6 + item::{ItemClass, ItemSearchOptions, KeyClass, Location, Reference, SearchResult}, 7 + key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token}, 8 + }; 9 + 3 10 // ── Public types ────────────────────────────────────────────────────────────── 4 11 5 12 #[derive(Debug, Serialize)] ··· 111 118 Ok(signature.to_bytes().to_vec()) 112 119 } 113 120 114 - // ── Real device (Secure Enclave) stubs ─────────────────────────────────────── 121 + // ── Real device (Secure Enclave) path ──────────────────────────────────────── 115 122 // 116 - // Phase 1 placeholder. The SE path is implemented in Phase 2. 117 - // These compile for `cargo build --target aarch64-apple-ios` but always error. 123 + // Phase 2 implementation using security_framework 3.x safe wrapper. 124 + // The SE private key is permanent and non-extractable; the public key and 125 + // application_label (SHA1 hash) are stored in the regular Keychain for lookup. 126 + 127 + /// Account names used to store SE key metadata in the regular Keychain. 128 + /// The SE private key itself is stored in the Secure Enclave and never leaves it. 129 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 130 + const SE_PUB_ACCOUNT: &str = "device-rotation-key-pub"; 131 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 132 + const SE_APP_LABEL_ACCOUNT: &str = "device-rotation-key-app-label"; 118 133 119 134 #[cfg(all(target_os = "ios", not(target_env = "sim")))] 120 135 pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 121 - Err(DeviceKeyError::KeyGenerationFailed) 136 + // Fast path: if we already stored the compressed public key, return it directly. 137 + // This avoids SE hardware interaction on every call after first generation. 138 + if let Ok(compressed) = crate::keychain::get_item(SE_PUB_ACCOUNT) { 139 + let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed); 140 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128). 141 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 142 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 143 + multikey.extend_from_slice(P256_MULTICODEC); 144 + multikey.extend_from_slice(&compressed); 145 + let key_id = format!( 146 + "did:key:{}", 147 + multibase::encode(multibase::Base::Base58Btc, &multikey) 148 + ); 149 + return Ok(DevicePublicKey { multibase, key_id }); 150 + } 151 + 152 + // Generate a new SE-backed P-256 key. 153 + // set_location(DataProtectionKeychain) is required — without it, security_framework sets 154 + // kSecAttrIsPermanent = false, meaning the key is not persisted to the Keychain and will 155 + // not survive app restart (breaking AC2.1). 156 + // set_access_control with PRIVATE_KEY_USAGE is required for SE keys — the SE enforces 157 + // that only explicitly-authorized operations can use the private key for signing. 158 + // 159 + // Note: SecAccessControl::create_with_protection takes Option<ProtectionMode> and a raw 160 + // flags u64. The PRIVATE_KEY_USAGE flag is kSecAccessControlPrivateKeyUsage = 1 << 30. 161 + // If the compiler reports an ambiguous type on the flags argument, use `0x4000_0000_u64`. 162 + let access_control = SecAccessControl::create_with_protection( 163 + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), 164 + 1 << 30, // kSecAccessControlPrivateKeyUsage 165 + ) 166 + .map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 167 + 168 + let mut opts = GenerateKeyOptions::default(); 169 + opts.set_key_type(KeyType::ec()) 170 + .set_size_in_bits(256) 171 + .set_token(Token::SecureEnclave) 172 + .set_label("ezpds-device-rotation-key") 173 + .set_location(Location::DataProtectionKeychain) 174 + .set_access_control(access_control); // takes ownership (by value) 175 + 176 + let priv_key = SecKey::new(&opts).map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 177 + 178 + // Retrieve the public key and its external representation. 179 + // SecKeyCopyExternalRepresentation on the *public* key returns the uncompressed 180 + // 65-byte X9.62 point (0x04 || x[32] || y[32]). 181 + let pub_key = priv_key 182 + .public_key() 183 + .ok_or(DeviceKeyError::KeyGenerationFailed)?; 184 + let pub_repr = pub_key 185 + .external_representation() 186 + .ok_or(DeviceKeyError::KeyGenerationFailed)?; 187 + let uncompressed: Vec<u8> = pub_repr.to_vec(); // 65 bytes 188 + 189 + // Compress: prefix byte = 0x02 (even y) or 0x03 (odd y); keep x[32]. 190 + // The last byte of the y coordinate determines parity. 191 + let mut compressed = [0u8; 33]; 192 + compressed[0] = if uncompressed[64] & 1 == 0 { 193 + 0x02 194 + } else { 195 + 0x03 196 + }; 197 + compressed[1..].copy_from_slice(&uncompressed[1..33]); 198 + 199 + // Store the compressed public key for the fast path on future calls. 200 + crate::keychain::store_item(SE_PUB_ACCOUNT, &compressed).map_err(|e| { 201 + DeviceKeyError::KeychainError { 202 + message: e.to_string(), 203 + } 204 + })?; 205 + 206 + // Store the application_label (OS-assigned SHA1 of public key, 20 bytes) 207 + // so sign() can locate the SE private key on future app launches. 208 + if let Some(app_label) = priv_key.application_label() { 209 + crate::keychain::store_item(SE_APP_LABEL_ACCOUNT, &app_label).map_err(|e| { 210 + DeviceKeyError::KeychainError { 211 + message: e.to_string(), 212 + } 213 + })?; 214 + } 215 + 216 + let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed); 217 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128). 218 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 219 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 220 + multikey.extend_from_slice(P256_MULTICODEC); 221 + multikey.extend_from_slice(&compressed); 222 + let key_id = format!( 223 + "did:key:{}", 224 + multibase::encode(multibase::Base::Base58Btc, &multikey) 225 + ); 226 + Ok(DevicePublicKey { multibase, key_id }) 122 227 } 123 228 124 229 #[cfg(all(target_os = "ios", not(target_env = "sim")))] 125 - pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 126 - Err(DeviceKeyError::KeyGenerationFailed) 230 + pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 231 + use p256::ecdsa::Signature; 232 + 233 + // Load the application_label to look up the SE private key. 234 + let app_label = 235 + crate::keychain::get_item(SE_APP_LABEL_ACCOUNT).map_err(|_| DeviceKeyError::KeyNotFound)?; 236 + 237 + // Find the SE private key in the Keychain by its application_label. 238 + // load_refs(true) returns SearchResult::Ref(CFType) containing the SecKeyRef. 239 + let mut search = ItemSearchOptions::new(); 240 + search 241 + .class(ItemClass::key()) 242 + .key_class(KeyClass::private()) 243 + .application_label(&app_label) 244 + .load_refs(true) 245 + .limit(1); 246 + 247 + let results = search.search().map_err(|_| DeviceKeyError::KeyNotFound)?; 248 + 249 + // Extract the SecKey from the typed Reference result. 250 + // SearchResult::Ref wraps a Reference enum; Reference::Key holds the already-wrapped SecKey. 251 + // No unsafe code is needed — security_framework handles the SecKeyRef wrapping internally. 252 + let sec_key = match results.into_iter().next() { 253 + Some(SearchResult::Ref(Reference::Key(key))) => key, 254 + _ => return Err(DeviceKeyError::KeyNotFound), 255 + }; 256 + 257 + // create_signature uses kSecKeyAlgorithmECDSASignatureMessageX962SHA256. 258 + // The SE hashes `data` with SHA-256 internally before signing. 259 + // Returns DER-encoded ECDSA signature (70–72 bytes). 260 + let der_sig = sec_key 261 + .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data) 262 + .map_err(|_| DeviceKeyError::SigningFailed)?; 263 + 264 + // Convert DER to raw 64-byte r||s (the format expected by ATProto/did:plc). 265 + // from_der() is a pure parser — it does NOT normalize low-S. Apple's SE may return 266 + // high-S signatures. normalize_s() ensures s <= order/2 as required by ATProto. 267 + let sig = Signature::from_der(&der_sig).map_err(|_| DeviceKeyError::InvalidSignature)?; 268 + let sig = sig.normalize_s().unwrap_or(sig); 269 + Ok(sig.to_bytes().to_vec()) 127 270 } 128 271 129 272 #[cfg(test)]