@algorandfoundation/react-native-passkey-autofill 1.0.0-canary.2 → 1.0.0-canary.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.nvmrc +1 -0
  2. package/.oxfmtrc.json +4 -0
  3. package/.oxlintrc.json +14 -0
  4. package/.releaserc +30 -0
  5. package/ARCHITECTURE.md +8 -0
  6. package/CHANGELOG.md +115 -0
  7. package/CONTRIBUTING.md +15 -5
  8. package/README.md +150 -11
  9. package/android/build.gradle +33 -10
  10. package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
  11. package/android/src/main/AndroidManifest.xml +9 -3
  12. package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
  13. package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
  14. package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
  15. package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
  16. package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
  17. package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
  18. package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
  19. package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
  20. package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
  21. package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
  22. package/android/src/main/res/values/styles.xml +10 -0
  23. package/app.plugin.js +593 -60
  24. package/assets/banner.png +0 -0
  25. package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
  26. package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
  27. package/build/ReactNativePasskeyAutofill.types.js +3 -1
  28. package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
  29. package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
  30. package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
  31. package/build/ReactNativePasskeyAutofillModule.js +21 -2
  32. package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
  33. package/build/index.d.ts +2 -2
  34. package/build/index.d.ts.map +1 -1
  35. package/build/index.js +2 -2
  36. package/build/index.js.map +1 -1
  37. package/e2e/README.md +155 -0
  38. package/e2e/driver/adb.ts +73 -0
  39. package/e2e/driver/capabilities.ts +106 -0
  40. package/e2e/driver/credentialProvider.ts +104 -0
  41. package/e2e/driver/fingerprint.ts +885 -0
  42. package/e2e/driver/selectors.ts +103 -0
  43. package/e2e/driver/session.ts +25 -0
  44. package/e2e/jest.config.ts +20 -0
  45. package/e2e/package.json +11 -0
  46. package/e2e/tests/passkey.test.ts +190 -0
  47. package/e2e/tsconfig.json +16 -0
  48. package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
  49. package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
  50. package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
  51. package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
  52. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
  53. package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
  54. package/ios/AutofillCredentialProvider/Prf.swift +89 -0
  55. package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
  56. package/ios/ReactNativePasskeyAutofill.podspec +11 -2
  57. package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
  58. package/lefthook.yml +14 -0
  59. package/package.json +115 -25
  60. package/pnpm-workspace.yaml +36 -0
  61. package/src/ReactNativePasskeyAutofill.types.ts +38 -0
  62. package/src/ReactNativePasskeyAutofillModule.ts +66 -3
  63. package/src/index.ts +2 -2
  64. package/tsconfig.json +2 -2
  65. package/.eslintrc.js +0 -5
  66. /package/android/libs/{dP256Android-release.aar → repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.aar} +0 -0
@@ -0,0 +1,612 @@
1
+ import AuthenticationServices
2
+ import CryptoKit
3
+ import Foundation
4
+ import Security
5
+
6
+ enum PasskeyCredentialStoreError: Error {
7
+ case appGroupUnavailable
8
+ case credentialNotFound
9
+ case credentialEncodingFailed
10
+ case credentialStorageFailed
11
+ case hdRootKeyUnavailable
12
+ case invalidPrivateKey
13
+ case signingFailed
14
+ }
15
+
16
+ struct StoredPasskeyCredential: Codable {
17
+ let credentialId: String
18
+ let relyingPartyIdentifier: String
19
+ let userName: String
20
+ let userHandle: String
21
+ let privateKey: String
22
+ let publicKey: String?
23
+ let createdAt: Double
24
+ let lastUsedAt: Double?
25
+ let parentKeyId: String?
26
+ }
27
+
28
+ final class PasskeyCredentialStore {
29
+ static let defaultSuiteNameKey = "ReactNativePasskeyAutofillAppGroup"
30
+ static let legacyCredentialKey = "ReactNativePasskeyAutofillCredentials"
31
+ static let defaultCredentialKey = "ReactNativePasskeyAutofillCredentialsV2"
32
+ static let defaultMasterKeyKey = "ReactNativePasskeyAutofillMasterKey"
33
+ static let defaultHdRootKeyIdKey = "ReactNativePasskeyAutofillHdRootKeyId"
34
+ static let defaultGetPasskeyActionKey = "ReactNativePasskeyAutofillGetPasskeyAction"
35
+ static let defaultCreatePasskeyActionKey = "ReactNativePasskeyAutofillCreatePasskeyAction"
36
+ static let defaultDiagnosticsKey = "ReactNativePasskeyAutofillDiagnostics"
37
+ static let defaultDeletedCredentialIdsKey = "ReactNativePasskeyAutofillDeletedCredentialIds"
38
+
39
+ private let defaults: UserDefaults
40
+ private let credentialKey: String
41
+
42
+ init?(
43
+ suiteName: String? = Bundle.main.object(
44
+ forInfoDictionaryKey: PasskeyCredentialStore.defaultSuiteNameKey
45
+ ) as? String,
46
+ credentialKey: String = PasskeyCredentialStore.defaultCredentialKey
47
+ ) {
48
+ guard let suiteName, let defaults = UserDefaults(suiteName: suiteName) else {
49
+ return nil
50
+ }
51
+ self.defaults = defaults
52
+ self.credentialKey = credentialKey
53
+ }
54
+
55
+ func allCredentials() -> [StoredPasskeyCredential] {
56
+ let keystoreCredentials = allKeystoreCredentials()
57
+ let legacyCredentials = allLegacyCredentials()
58
+ var credentialsById: [String: StoredPasskeyCredential] = [:]
59
+
60
+ for credential in legacyCredentials {
61
+ credentialsById[credential.credentialId] = credential
62
+ }
63
+
64
+ for credential in keystoreCredentials {
65
+ credentialsById[credential.credentialId] = credential
66
+ }
67
+
68
+ return Array(credentialsById.values)
69
+ }
70
+
71
+ private func allLegacyCredentials() -> [StoredPasskeyCredential] {
72
+ guard let data = defaults.data(forKey: credentialKey),
73
+ let credentials = try? JSONDecoder().decode([StoredPasskeyCredential].self, from: data)
74
+ else { return [] }
75
+ return credentials
76
+ }
77
+
78
+ func credentials(relyingPartyIdentifier: String) -> [StoredPasskeyCredential] {
79
+ allCredentials().filter { $0.relyingPartyIdentifier == relyingPartyIdentifier }
80
+ }
81
+
82
+ func credential(id: Data) -> StoredPasskeyCredential? {
83
+ let encodedId = id.base64EncodedString()
84
+ let urlEncodedId = id.base64URLEncodedString()
85
+ return allCredentials().first { $0.credentialId == encodedId || $0.credentialId == urlEncodedId }
86
+ }
87
+
88
+ func save(_ credential: StoredPasskeyCredential) throws {
89
+ unmarkCredentialDeleted(id: credential.credentialId)
90
+ #if PASSKEY_AUTOFILL_EXTENSION
91
+ try saveKeystoreCredential(credential)
92
+ #else
93
+ var credentials = allLegacyCredentials().filter { $0.credentialId != credential.credentialId }
94
+ credentials.append(credential)
95
+ try replace(credentials)
96
+ #endif
97
+ }
98
+
99
+ func removeCredential(id: String) throws {
100
+ let candidateIds = credentialIdCandidates(id)
101
+ markCredentialsDeleted(ids: candidateIds)
102
+ var credentials = allLegacyCredentials()
103
+ let originalCount = credentials.count
104
+ credentials.removeAll { candidateIds.contains($0.credentialId) }
105
+ if credentials.count != originalCount {
106
+ try replace(credentials)
107
+ }
108
+
109
+ guard let appGroup = Bundle.main.object(forInfoDictionaryKey: Self.defaultSuiteNameKey) as? String else {
110
+ return
111
+ }
112
+
113
+ for candidateId in candidateIds {
114
+ try? PasskeyKeystoreMMKV.removeValue(forKey: candidateId, appGroup: appGroup)
115
+ }
116
+ }
117
+
118
+ func replace(_ credentials: [StoredPasskeyCredential]) throws {
119
+ let data = try JSONEncoder().encode(credentials)
120
+ defaults.set(data, forKey: credentialKey)
121
+ }
122
+
123
+ private func allKeystoreCredentials() -> [StoredPasskeyCredential] {
124
+ guard let masterKey = masterKey(),
125
+ let appGroup = Bundle.main.object(forInfoDictionaryKey: Self.defaultSuiteNameKey) as? String
126
+ else {
127
+ appendDiagnostic("keystore credentials unavailable: missing master key or app group")
128
+ return []
129
+ }
130
+
131
+ let keys: [String] = PasskeyKeystoreMMKV.allKeys(forAppGroup: appGroup, error: nil)
132
+ appendDiagnostic("keystore allKeys count: \(keys.count)")
133
+ return keys.compactMap { key -> StoredPasskeyCredential? in
134
+ guard let payload = try? PasskeyKeystoreMMKV.string(forKey: key, appGroup: appGroup),
135
+ let keyData = try? decodeKeystorePayload(payload, masterKey: masterKey),
136
+ let id = keyData["id"] as? String,
137
+ let publicKey = dataArray(keyData["publicKey"]),
138
+ let privateKey = dataArray(keyData["privateKey"])
139
+ else {
140
+ appendDiagnostic("skipping keystore key: \(key)")
141
+ return nil
142
+ }
143
+
144
+ let metadata = keyData["metadata"] as? [String: Any]
145
+ let origin = metadata?["origin"] as? String ?? keyData["origin"] as? String ?? ""
146
+ let userHandle = metadata?["userHandle"] as? String ?? keyData["userHandle"] as? String ?? ""
147
+ let parentKeyId = metadata?["parentKeyId"] as? String ?? keyData["parentKeyId"] as? String
148
+ guard !origin.isEmpty, !userHandle.isEmpty else {
149
+ appendDiagnostic("skipping keystore credential missing metadata: \(id)")
150
+ return nil
151
+ }
152
+ let rawUserName = metadata?["userName"] as? String ?? keyData["userName"] as? String ?? userHandle
153
+
154
+ return StoredPasskeyCredential(
155
+ credentialId: id,
156
+ relyingPartyIdentifier: origin.relyingPartyIdentifier,
157
+ userName: rawUserName.passkeyDisplayName,
158
+ userHandle: userHandle,
159
+ privateKey: privateKey.base64EncodedString(),
160
+ publicKey: publicKey.base64EncodedString(),
161
+ createdAt: metadata?["createdAt"] as? Double ?? Date().timeIntervalSince1970,
162
+ lastUsedAt: metadata?["lastUsedAt"] as? Double,
163
+ parentKeyId: parentKeyId
164
+ )
165
+ }
166
+ }
167
+
168
+ #if PASSKEY_AUTOFILL_EXTENSION
169
+ private func saveKeystoreCredential(_ credential: StoredPasskeyCredential) throws {
170
+ guard let masterKey = masterKey(),
171
+ let appGroup = Bundle.main.object(forInfoDictionaryKey: Self.defaultSuiteNameKey) as? String
172
+ else {
173
+ throw PasskeyCredentialStoreError.appGroupUnavailable
174
+ }
175
+ guard let privateKey = Data(base64URLEncoded: credential.privateKey) ?? Data(base64Encoded: credential.privateKey),
176
+ let publicKeyString = credential.publicKey,
177
+ let publicKey = Data(base64URLEncoded: publicKeyString) ?? Data(base64Encoded: publicKeyString)
178
+ else {
179
+ throw PasskeyCredentialStoreError.invalidPrivateKey
180
+ }
181
+
182
+ var metadata: [String: Any] = [
183
+ "origin": credential.relyingPartyIdentifier,
184
+ "userName": credential.userName.passkeyDisplayName,
185
+ "userHandle": credential.userHandle,
186
+ "userId": credential.userHandle,
187
+ "count": 0,
188
+ "createdAt": credential.createdAt,
189
+ "registered": true,
190
+ ]
191
+ if let lastUsedAt = credential.lastUsedAt {
192
+ metadata["lastUsedAt"] = lastUsedAt
193
+ }
194
+ if let parentKeyId = credential.parentKeyId ?? hdRootKeyId() {
195
+ metadata["parentKeyId"] = parentKeyId
196
+ }
197
+
198
+ let keyData: [String: Any] = [
199
+ "id": credential.credentialId,
200
+ "type": "hd-derived-p256",
201
+ "algorithm": "P256",
202
+ "extractable": false,
203
+ "keyUsages": ["sign"],
204
+ "name": "Passkey: \(credential.relyingPartyIdentifier)",
205
+ "privateKey": privateKey.byteArray,
206
+ "publicKey": publicKey.byteArray,
207
+ "metadata": metadata,
208
+ ]
209
+
210
+ let encoded = try encodeKeyData(keyData)
211
+ let encrypted = try encryptData(masterKey, encoded)
212
+ do {
213
+ try PasskeyKeystoreMMKV.setString(encrypted, forKey: credential.credentialId, appGroup: appGroup)
214
+ } catch {
215
+ throw PasskeyCredentialStoreError.credentialStorageFailed
216
+ }
217
+ }
218
+
219
+ func recordCredentialUsage(id: String) {
220
+ guard let masterKey = masterKey(),
221
+ let appGroup = Bundle.main.object(forInfoDictionaryKey: Self.defaultSuiteNameKey) as? String
222
+ else { return }
223
+
224
+ for candidate in credentialIdCandidates(id) {
225
+ guard let payload = try? PasskeyKeystoreMMKV.string(forKey: candidate, appGroup: appGroup),
226
+ var keyData = try? decodeKeystorePayload(payload, masterKey: masterKey)
227
+ else { continue }
228
+
229
+ var metadata = keyData["metadata"] as? [String: Any] ?? [:]
230
+ metadata["lastUsedAt"] = Date().timeIntervalSince1970
231
+ metadata["count"] = ((metadata["count"] as? Int) ?? 0) + 1
232
+ keyData["metadata"] = metadata
233
+
234
+ guard let encoded = try? encodeKeyData(keyData),
235
+ let encrypted = try? encryptData(masterKey, encoded)
236
+ else { return }
237
+ try? PasskeyKeystoreMMKV.setString(encrypted, forKey: candidate, appGroup: appGroup)
238
+ return
239
+ }
240
+ }
241
+ #endif
242
+
243
+ func clear() {
244
+ defaults.removeObject(forKey: credentialKey)
245
+ defaults.removeObject(forKey: Self.legacyCredentialKey)
246
+ defaults.removeObject(forKey: Self.defaultDeletedCredentialIdsKey)
247
+ defaults.removeObject(forKey: Self.defaultMasterKeyKey)
248
+ defaults.removeObject(forKey: Self.defaultHdRootKeyIdKey)
249
+ defaults.removeObject(forKey: Self.defaultGetPasskeyActionKey)
250
+ defaults.removeObject(forKey: Self.defaultCreatePasskeyActionKey)
251
+ }
252
+
253
+ func saveMasterKey(_ secret: String) {
254
+ defaults.set(Self.normalizeSecret(secret).base64URLEncodedString(), forKey: Self.defaultMasterKeyKey)
255
+ }
256
+
257
+ func masterKey() -> Data? {
258
+ guard let secret = defaults.string(forKey: Self.defaultMasterKeyKey) else {
259
+ return nil
260
+ }
261
+ return Data(base64URLEncoded: secret) ?? Data(base64Encoded: secret)
262
+ }
263
+
264
+ func isMasterKeyAvailable() -> Bool {
265
+ masterKey() != nil
266
+ }
267
+
268
+ func saveHdRootKeyId(_ id: String) {
269
+ defaults.set(id, forKey: Self.defaultHdRootKeyIdKey)
270
+ }
271
+
272
+ func hdRootKeyId() -> String? {
273
+ defaults.string(forKey: Self.defaultHdRootKeyIdKey)
274
+ }
275
+
276
+ func hdRootKeySecret() throws -> Data {
277
+ guard let masterKey = masterKey(),
278
+ let hdRootKeyId = hdRootKeyId(),
279
+ let appGroup = Bundle.main.object(forInfoDictionaryKey: Self.defaultSuiteNameKey) as? String,
280
+ let payload = try? PasskeyKeystoreMMKV.string(forKey: hdRootKeyId, appGroup: appGroup)
281
+ else {
282
+ throw PasskeyCredentialStoreError.hdRootKeyUnavailable
283
+ }
284
+
285
+ let keyData = try decodeKeystorePayload(payload, masterKey: masterKey)
286
+ if let seed = dataArray(keyData["seed"]) ?? dataArray(keyData["privateKey"]) {
287
+ return seed
288
+ }
289
+ if let seed = keyData["seed"] as? String ?? keyData["privateKey"] as? String,
290
+ let data = Self.secretStringData(seed)
291
+ {
292
+ return data
293
+ }
294
+ throw PasskeyCredentialStoreError.hdRootKeyUnavailable
295
+ }
296
+
297
+ func configureIntentActions(getPasskeyAction: String, createPasskeyAction: String) {
298
+ defaults.set(getPasskeyAction, forKey: Self.defaultGetPasskeyActionKey)
299
+ defaults.set(createPasskeyAction, forKey: Self.defaultCreatePasskeyActionKey)
300
+ }
301
+
302
+ func appendDiagnostic(_ message: String) {
303
+ var diagnostics = defaults.array(forKey: Self.defaultDiagnosticsKey) as? [String] ?? []
304
+ let timestamp = ISO8601DateFormatter().string(from: Date())
305
+ diagnostics.append("\(timestamp) \(message)")
306
+ defaults.set(Array(diagnostics.suffix(50)), forKey: Self.defaultDiagnosticsKey)
307
+ }
308
+
309
+ func diagnostics() -> [String] {
310
+ defaults.array(forKey: Self.defaultDiagnosticsKey) as? [String] ?? []
311
+ }
312
+
313
+ func isCredentialDeleted(id: Data) -> Bool {
314
+ isCredentialDeleted(id: id.base64EncodedString()) || isCredentialDeleted(id: id.base64URLEncodedString())
315
+ }
316
+
317
+ func replaceIdentityStore() async throws {
318
+ guard #available(iOS 17.0, *) else {
319
+ return
320
+ }
321
+
322
+ let identities = allCredentials().map { credential in
323
+ ASPasskeyCredentialIdentity(
324
+ relyingPartyIdentifier: credential.relyingPartyIdentifier,
325
+ userName: credential.userName,
326
+ credentialID: Data(base64URLEncoded: credential.credentialId) ?? Data(),
327
+ userHandle: Data(base64URLEncoded: credential.userHandle) ?? Data(credential.userHandle.utf8),
328
+ recordIdentifier: credential.credentialId
329
+ )
330
+ }
331
+
332
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
333
+ ASCredentialIdentityStore.shared.replaceCredentialIdentities(identities) { success, error in
334
+ if let error {
335
+ continuation.resume(throwing: error)
336
+ } else if success {
337
+ continuation.resume()
338
+ } else {
339
+ continuation.resume(throwing: PasskeyCredentialStoreError.credentialNotFound)
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ func removeAllIdentities() async throws {
346
+ guard #available(iOS 17.0, *) else {
347
+ return
348
+ }
349
+
350
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
351
+ ASCredentialIdentityStore.shared.removeAllCredentialIdentities { success, error in
352
+ if let error {
353
+ continuation.resume(throwing: error)
354
+ } else if success {
355
+ continuation.resume()
356
+ } else {
357
+ continuation.resume(throwing: PasskeyCredentialStoreError.credentialNotFound)
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ private static func normalizeSecret(_ secret: String) -> Data {
364
+ let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
365
+ if trimmed.count % 2 == 0,
366
+ trimmed.allSatisfy({ $0.isHexDigit }),
367
+ let data = Data(hex: trimmed)
368
+ {
369
+ return data
370
+ }
371
+ return Data(trimmed.utf8)
372
+ }
373
+
374
+ private func credentialIdCandidates(_ id: String) -> Set<String> {
375
+ var candidates: Set<String> = [id]
376
+ if let data = Data(base64URLEncoded: id) ?? Data(base64Encoded: id) {
377
+ candidates.insert(data.base64EncodedString())
378
+ candidates.insert(data.base64URLEncodedString())
379
+ }
380
+ return candidates
381
+ }
382
+
383
+ private func deletedCredentialIds() -> Set<String> {
384
+ Set(defaults.stringArray(forKey: Self.defaultDeletedCredentialIdsKey) ?? [])
385
+ }
386
+
387
+ private func isCredentialDeleted(id: String) -> Bool {
388
+ !deletedCredentialIds().isDisjoint(with: credentialIdCandidates(id))
389
+ }
390
+
391
+ private func markCredentialsDeleted(ids: Set<String>) {
392
+ var deletedIds = deletedCredentialIds()
393
+ deletedIds.formUnion(ids)
394
+ defaults.set(Array(deletedIds), forKey: Self.defaultDeletedCredentialIdsKey)
395
+ }
396
+
397
+ private func unmarkCredentialDeleted(id: String) {
398
+ var deletedIds = deletedCredentialIds()
399
+ deletedIds.subtract(credentialIdCandidates(id))
400
+ defaults.set(Array(deletedIds), forKey: Self.defaultDeletedCredentialIdsKey)
401
+ }
402
+
403
+ private func encodeKeyData(_ keyData: [String: Any]) throws -> String {
404
+ let jsonData = try JSONSerialization.data(withJSONObject: keyData, options: [])
405
+ return jsonData.base64URLEncodedString()
406
+ }
407
+
408
+ private func decodeKeystorePayload(_ payload: String, masterKey: Data) throws -> [String: Any] {
409
+ let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
410
+ if trimmed.hasPrefix("{"),
411
+ let payloadData = trimmed.data(using: .utf8),
412
+ let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
413
+ json["iv"] == nil || json["tag"] == nil || json["content"] == nil
414
+ {
415
+ return json
416
+ }
417
+
418
+ let encoded = trimmed.hasPrefix("{") ? try decryptData(masterKey, trimmed) : trimmed
419
+ guard let jsonData = Data(base64URLEncoded: encoded) ?? encoded.data(using: .utf8),
420
+ let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
421
+ else {
422
+ throw PasskeyCredentialStoreError.credentialEncodingFailed
423
+ }
424
+ return json
425
+ }
426
+
427
+ private func encryptData(_ key: Data, _ plaintext: String) throws -> String {
428
+ let symmetricKey = SymmetricKey(data: key)
429
+ let nonceData = Data((0..<12).map { _ in UInt8.random(in: 0...255) })
430
+ let nonce = try AES.GCM.Nonce(data: nonceData)
431
+ let sealedBox = try AES.GCM.seal(Data(plaintext.utf8), using: symmetricKey, nonce: nonce)
432
+
433
+ let payload: [String: String] = [
434
+ "iv": nonceData.base64EncodedString(),
435
+ "tag": sealedBox.tag.base64EncodedString(),
436
+ "content": sealedBox.ciphertext.base64EncodedString(),
437
+ ]
438
+ let data = try JSONSerialization.data(withJSONObject: payload, options: [])
439
+ guard let string = String(data: data, encoding: .utf8) else {
440
+ throw PasskeyCredentialStoreError.credentialEncodingFailed
441
+ }
442
+ return string
443
+ }
444
+
445
+ private func decryptData(_ key: Data, _ payload: String) throws -> String {
446
+ guard let payloadData = payload.data(using: .utf8),
447
+ let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: String],
448
+ let iv = Data(base64Encoded: json["iv"] ?? ""),
449
+ let tag = Data(base64Encoded: json["tag"] ?? ""),
450
+ let content = Data(base64Encoded: json["content"] ?? "")
451
+ else {
452
+ throw PasskeyCredentialStoreError.credentialEncodingFailed
453
+ }
454
+
455
+ let sealedBox = try AES.GCM.SealedBox(
456
+ nonce: AES.GCM.Nonce(data: iv),
457
+ ciphertext: content,
458
+ tag: tag
459
+ )
460
+ let decrypted = try AES.GCM.open(sealedBox, using: SymmetricKey(data: key))
461
+ guard let string = String(data: decrypted, encoding: .utf8) else {
462
+ throw PasskeyCredentialStoreError.credentialEncodingFailed
463
+ }
464
+ return string
465
+ }
466
+
467
+ private func dataArray(_ value: Any?) -> Data? {
468
+ guard let array = value as? [Any] else {
469
+ return nil
470
+ }
471
+ var data = Data(capacity: array.count)
472
+ for item in array {
473
+ if let int = item as? Int {
474
+ data.append(UInt8(int & 0xff))
475
+ } else if let number = item as? NSNumber {
476
+ data.append(UInt8(truncating: number))
477
+ } else {
478
+ return nil
479
+ }
480
+ }
481
+ return data
482
+ }
483
+
484
+ private static func secretStringData(_ secret: String) -> Data? {
485
+ let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
486
+ if trimmed.hasPrefix("0x") || trimmed.hasPrefix("0X") {
487
+ return Data(hex: String(trimmed.dropFirst(2)))
488
+ }
489
+ return Data(hex: trimmed) ?? Data(base64URLEncoded: trimmed) ?? Data(base64Encoded: trimmed)
490
+ }
491
+ }
492
+
493
+ extension StoredPasskeyCredential {
494
+ var credentialIdData: Data {
495
+ Data(base64URLEncoded: credentialId) ?? Data()
496
+ }
497
+
498
+ var userHandleData: Data {
499
+ Data(base64URLEncoded: userHandle) ?? Data(userHandle.utf8)
500
+ }
501
+
502
+ func privateSecKey() throws -> SecKey {
503
+ guard let privateKeyData = Data(base64URLEncoded: privateKey) ?? Data(base64Encoded: privateKey)
504
+ else {
505
+ throw PasskeyCredentialStoreError.invalidPrivateKey
506
+ }
507
+
508
+ let attributes: [String: Any] = [
509
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
510
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
511
+ kSecAttrKeySizeInBits as String: 256,
512
+ ]
513
+
514
+ var error: Unmanaged<CFError>?
515
+ guard let key = SecKeyCreateWithData(privateKeyData as CFData, attributes as CFDictionary, &error)
516
+ else {
517
+ throw error?.takeRetainedValue() ?? PasskeyCredentialStoreError.invalidPrivateKey
518
+ }
519
+ return key
520
+ }
521
+
522
+ func sign(_ data: Data) throws -> Data {
523
+ if let privateKeyData = Data(base64URLEncoded: privateKey) ?? Data(base64Encoded: privateKey) {
524
+ if let key = try? P256.Signing.PrivateKey(rawRepresentation: privateKeyData) {
525
+ return try key.signature(for: data).derRepresentation
526
+ }
527
+
528
+ if let key = try? P256.Signing.PrivateKey(derRepresentation: privateKeyData) {
529
+ return try key.signature(for: data).derRepresentation
530
+ }
531
+ }
532
+
533
+ let key = try privateSecKey()
534
+ var error: Unmanaged<CFError>?
535
+ guard let signature = SecKeyCreateSignature(key, .ecdsaSignatureMessageX962SHA256, data as CFData, &error)
536
+ as Data?
537
+ else {
538
+ throw error?.takeRetainedValue() ?? PasskeyCredentialStoreError.signingFailed
539
+ }
540
+ return signature
541
+ }
542
+ }
543
+
544
+ extension Data {
545
+ var byteArray: [Int] {
546
+ map { Int($0) }
547
+ }
548
+
549
+ init?(hex: String) {
550
+ let normalized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
551
+ guard normalized.count % 2 == 0 else {
552
+ return nil
553
+ }
554
+
555
+ var bytes = Data(capacity: normalized.count / 2)
556
+ var index = normalized.startIndex
557
+ while index < normalized.endIndex {
558
+ let nextIndex = normalized.index(index, offsetBy: 2)
559
+ guard let byte = UInt8(normalized[index..<nextIndex], radix: 16) else {
560
+ return nil
561
+ }
562
+ bytes.append(byte)
563
+ index = nextIndex
564
+ }
565
+ self = bytes
566
+ }
567
+
568
+ init?(base64URLEncoded string: String) {
569
+ var base64 = string.replacingOccurrences(of: "-", with: "+")
570
+ .replacingOccurrences(of: "_", with: "/")
571
+ let remainder = base64.count % 4
572
+ if remainder > 0 {
573
+ base64.append(String(repeating: "=", count: 4 - remainder))
574
+ }
575
+ self.init(base64Encoded: base64)
576
+ }
577
+
578
+ func base64URLEncodedString() -> String {
579
+ base64EncodedString()
580
+ .replacingOccurrences(of: "+", with: "-")
581
+ .replacingOccurrences(of: "/", with: "_")
582
+ .replacingOccurrences(of: "=", with: "")
583
+ }
584
+ }
585
+
586
+ extension String {
587
+ var relyingPartyIdentifier: String {
588
+ guard let url = URL(string: self), let host = url.host else {
589
+ return self
590
+ }
591
+ return host
592
+ }
593
+
594
+ var passkeyDisplayName: String {
595
+ let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
596
+ guard !trimmed.isEmpty,
597
+ let decodedData = Data(base64URLEncoded: trimmed) ?? Data(base64Encoded: trimmed),
598
+ let decoded = String(data: decodedData, encoding: .utf8)
599
+ else {
600
+ return self
601
+ }
602
+
603
+ let normalized = decoded.trimmingCharacters(in: .whitespacesAndNewlines)
604
+ guard !normalized.isEmpty,
605
+ normalized.unicodeScalars.allSatisfy({ $0.value >= 32 && $0.value != 127 })
606
+ else {
607
+ return self
608
+ }
609
+
610
+ return normalized
611
+ }
612
+ }
@@ -0,0 +1,25 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface PasskeyKeystoreMMKV : NSObject
6
+
7
+ + (BOOL)setString:(NSString *)value
8
+ forKey:(NSString *)key
9
+ appGroup:(NSString *)appGroup
10
+ error:(NSError * _Nullable * _Nullable)error;
11
+
12
+ + (nullable NSString *)stringForKey:(NSString *)key
13
+ appGroup:(NSString *)appGroup
14
+ error:(NSError * _Nullable * _Nullable)error;
15
+
16
+ + (NSArray<NSString *> *)allKeysForAppGroup:(NSString *)appGroup
17
+ error:(NSError * _Nullable * _Nullable)error;
18
+
19
+ + (BOOL)removeValueForKey:(NSString *)key
20
+ appGroup:(NSString *)appGroup
21
+ error:(NSError * _Nullable * _Nullable)error;
22
+
23
+ @end
24
+
25
+ NS_ASSUME_NONNULL_END