Skip to content

Commit e5a8581

Browse files
SSD DDDclaude
authored andcommitted
feat(security): store the device identity key in the Keychain, not a plaintext plist
The identity feature (last wave) generated a persistent Ed25519 signing key but kept it in UserDefaults — a base64 plist on disk that any local read can lift, letting an attacker impersonate the device forever. Move it to the Keychain (kSecClassGenericPassword, AccessibleAfterFirstUnlock). deviceIdentity() now: (1) loads from the Keychain; (2) one-time MIGRATES a legacy UserDefaults key into the Keychain and scrubs the plist copy, so existing installs keep their identity and every peer's TOFU pin stays valid; (3) first run generates + stores. If the Keychain is unavailable (an unsigned build), it falls back to UserDefaults so the app still runs. Both platforms. Verified. A standalone harness over the real MeshCrypto (both platforms): first run stores in the Keychain and NOT the plist; the identity persists across calls; raw save/load round-trips; and migration returns the SAME identity while moving it to the Keychain and removing the plist copy — ALL PASS (Keychain confirmed working headless first). Live on the SIGNED Mac app: a loopback call establishes, the safety number is 16408198304 — the SAME value as before the migration (identity preserved, pins intact) — and `defaults read com.trinet.monitor trinetIdentityKeyV1` is now empty (plaintext scrubbed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e930d55 commit e5a8581

2 files changed

Lines changed: 67 additions & 8 deletions

File tree

phone/TriNetVideo/VideoPipeline.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Vision
77
import CoreImage
88
import Network
99
import CryptoKit
10+
import Security
1011
import UIKit // UIDevice for the discovery default name
1112

1213
// MARK: - Camera Controller
@@ -1638,12 +1639,39 @@ final class MeshCrypto {
16381639
self.identityPriv = identity
16391640
}
16401641

1642+
// Long-term signing key in the KEYCHAIN (not a plaintext plist). Migrates a legacy
1643+
// UserDefaults key once; falls back to UserDefaults only if the Keychain is unavailable.
1644+
static let kcService = "com.trinet.identity"
1645+
static let kcAccount = "device-ed25519"
1646+
private static func kcQuery() -> [String: Any] {
1647+
[kSecClass as String: kSecClassGenericPassword,
1648+
kSecAttrService as String: kcService, kSecAttrAccount as String: kcAccount]
1649+
}
1650+
static func keychainLoad() -> Data? {
1651+
var q = kcQuery(); q[kSecReturnData as String] = true
1652+
var out: CFTypeRef?
1653+
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? (out as? Data) : nil
1654+
}
1655+
@discardableResult static func keychainSave(_ data: Data) -> Bool {
1656+
SecItemDelete(kcQuery() as CFDictionary)
1657+
var add = kcQuery()
1658+
add[kSecValueData as String] = data
1659+
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
1660+
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
1661+
}
1662+
16411663
static func deviceIdentity() -> Curve25519.Signing.PrivateKey {
1642-
let k = "trinetIdentityKeyV1"
1643-
if let b64 = UserDefaults.standard.string(forKey: k), let raw = Data(base64Encoded: b64),
1644-
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) { return key }
1664+
let legacyKey = "trinetIdentityKeyV1"
1665+
if let raw = keychainLoad(), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) { return key }
1666+
if let b64 = UserDefaults.standard.string(forKey: legacyKey), let raw = Data(base64Encoded: b64),
1667+
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
1668+
if keychainSave(raw) { UserDefaults.standard.removeObject(forKey: legacyKey) }
1669+
return key
1670+
}
16451671
let key = Curve25519.Signing.PrivateKey()
1646-
UserDefaults.standard.set(key.rawRepresentation.base64EncodedString(), forKey: k)
1672+
if !keychainSave(key.rawRepresentation) {
1673+
UserDefaults.standard.set(key.rawRepresentation.base64EncodedString(), forKey: legacyKey)
1674+
}
16471675
return key
16481676
}
16491677

phone/desktop/TriNetVideo/MeshCrypto.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// Wire (data): ChaChaPoly.combined sealed under the derived session key.
1414
import Foundation
1515
import CryptoKit
16+
import Security
1617

1718
final class MeshCrypto {
1819
// PSK authenticates the ephemeral exchange (not the data). Swap for real
@@ -73,12 +74,42 @@ final class MeshCrypto {
7374
}
7475

7576
// Load (or first-run generate + persist) this device's long-term signing key.
77+
// The long-term signing key lives in the KEYCHAIN, not UserDefaults — a private key
78+
// must not sit in a plaintext plist on disk. Order: (1) load from Keychain; (2) migrate
79+
// a legacy UserDefaults key into the Keychain once (so existing installs keep their
80+
// identity + peers' pins stay valid); (3) first run — generate + store. If the Keychain
81+
// is unavailable (unsigned build), fall back to UserDefaults so the app still works.
82+
static let kcService = "com.trinet.identity"
83+
static let kcAccount = "device-ed25519"
84+
private static func kcQuery() -> [String: Any] {
85+
[kSecClass as String: kSecClassGenericPassword,
86+
kSecAttrService as String: kcService, kSecAttrAccount as String: kcAccount]
87+
}
88+
static func keychainLoad() -> Data? {
89+
var q = kcQuery(); q[kSecReturnData as String] = true
90+
var out: CFTypeRef?
91+
return SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess ? (out as? Data) : nil
92+
}
93+
@discardableResult static func keychainSave(_ data: Data) -> Bool {
94+
SecItemDelete(kcQuery() as CFDictionary)
95+
var add = kcQuery()
96+
add[kSecValueData as String] = data
97+
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
98+
return SecItemAdd(add as CFDictionary, nil) == errSecSuccess
99+
}
100+
76101
static func deviceIdentity() -> Curve25519.Signing.PrivateKey {
77-
let k = "trinetIdentityKeyV1"
78-
if let b64 = UserDefaults.standard.string(forKey: k), let raw = Data(base64Encoded: b64),
79-
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) { return key }
102+
let legacyKey = "trinetIdentityKeyV1"
103+
if let raw = keychainLoad(), let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) { return key }
104+
if let b64 = UserDefaults.standard.string(forKey: legacyKey), let raw = Data(base64Encoded: b64),
105+
let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: raw) {
106+
if keychainSave(raw) { UserDefaults.standard.removeObject(forKey: legacyKey) } // migrate + scrub the plist
107+
return key
108+
}
80109
let key = Curve25519.Signing.PrivateKey()
81-
UserDefaults.standard.set(key.rawRepresentation.base64EncodedString(), forKey: k)
110+
if !keychainSave(key.rawRepresentation) {
111+
UserDefaults.standard.set(key.rawRepresentation.base64EncodedString(), forKey: legacyKey) // fallback
112+
}
82113
return key
83114
}
84115

0 commit comments

Comments
 (0)