@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.
- package/.nvmrc +1 -0
- package/.oxfmtrc.json +4 -0
- package/.oxlintrc.json +14 -0
- package/.releaserc +30 -0
- package/ARCHITECTURE.md +8 -0
- package/CHANGELOG.md +115 -0
- package/CONTRIBUTING.md +15 -5
- package/README.md +150 -11
- package/android/build.gradle +33 -10
- package/android/libs/repo/co/algorand/dP256Android/1.0.0/dP256Android-1.0.0.pom +9 -0
- package/android/src/main/AndroidManifest.xml +9 -3
- package/android/src/main/java/co/algorand/passkeyautofill/CreatePasskeyActivity.kt +396 -44
- package/android/src/main/java/co/algorand/passkeyautofill/GetPasskeyActivity.kt +583 -44
- package/android/src/main/java/co/algorand/passkeyautofill/Prf.kt +184 -0
- package/android/src/main/java/co/algorand/passkeyautofill/ReactNativePasskeyAutofillModule.kt +190 -2
- package/android/src/main/java/co/algorand/passkeyautofill/WebAuthn.kt +135 -0
- package/android/src/main/java/co/algorand/passkeyautofill/auth/BiometricRequirement.kt +86 -0
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/Credential.kt +2 -1
- package/android/src/main/java/co/algorand/passkeyautofill/credentials/CredentialRepository.kt +314 -53
- package/android/src/main/java/co/algorand/passkeyautofill/service/PasskeyAutofillCredentialProviderService.kt +152 -38
- package/android/src/main/java/co/algorand/passkeyautofill/utils/PasskeyUtils.kt +119 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/app.plugin.js +593 -60
- package/assets/banner.png +0 -0
- package/build/ReactNativePasskeyAutofill.types.d.ts +41 -1
- package/build/ReactNativePasskeyAutofill.types.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofill.types.js +3 -1
- package/build/ReactNativePasskeyAutofill.types.js.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.d.ts +41 -4
- package/build/ReactNativePasskeyAutofillModule.d.ts.map +1 -1
- package/build/ReactNativePasskeyAutofillModule.js +21 -2
- package/build/ReactNativePasskeyAutofillModule.js.map +1 -1
- package/build/index.d.ts +2 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -2
- package/build/index.js.map +1 -1
- package/e2e/README.md +155 -0
- package/e2e/driver/adb.ts +73 -0
- package/e2e/driver/capabilities.ts +106 -0
- package/e2e/driver/credentialProvider.ts +104 -0
- package/e2e/driver/fingerprint.ts +885 -0
- package/e2e/driver/selectors.ts +103 -0
- package/e2e/driver/session.ts +25 -0
- package/e2e/jest.config.ts +20 -0
- package/e2e/package.json +11 -0
- package/e2e/tests/passkey.test.ts +190 -0
- package/e2e/tsconfig.json +16 -0
- package/ios/AutofillCredentialProvider/BiometricRequirement.swift +34 -0
- package/ios/AutofillCredentialProvider/CredentialProviderViewController.swift +570 -0
- package/ios/AutofillCredentialProvider/PasskeyAutofillCredentialProvider-Bridging-Header.h +1 -0
- package/ios/AutofillCredentialProvider/PasskeyCredentialStore.swift +612 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.h +25 -0
- package/ios/AutofillCredentialProvider/PasskeyKeystoreMMKV.mm +86 -0
- package/ios/AutofillCredentialProvider/Prf.swift +89 -0
- package/ios/AutofillCredentialProvider/WebAuthn.swift +186 -0
- package/ios/ReactNativePasskeyAutofill.podspec +11 -2
- package/ios/ReactNativePasskeyAutofillModule.swift +170 -15
- package/lefthook.yml +14 -0
- package/package.json +115 -25
- package/pnpm-workspace.yaml +36 -0
- package/src/ReactNativePasskeyAutofill.types.ts +38 -0
- package/src/ReactNativePasskeyAutofillModule.ts +66 -3
- package/src/index.ts +2 -2
- package/tsconfig.json +2 -2
- package/.eslintrc.js +0 -5
- /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
|