@breeztech/breez-sdk-spark-react-native 0.21.0 → 0.22.0
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/android/build.gradle +5 -0
- package/android/src/main/java/com/breeztech/breezsdkspark/BreezSdkSparkReactNativePackage.kt +12 -4
- package/android/src/main/kotlin/com/breeztech/breezsdkspark/BreezSdkSparkPasskeyModule.kt +306 -0
- package/android/src/main/kotlin/technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt +955 -0
- package/ios/BreezSdkSparkPasskey.m +33 -0
- package/ios/BreezSdkSparkPasskey.swift +201 -0
- package/ios/PasskeyAssertionCore.swift +881 -0
- package/ios/PasskeyPRFHelper.h +52 -0
- package/ios/PasskeyPRFHelper.m +73 -0
- package/lib/commonjs/generated/breez_sdk_spark.js +19 -6
- package/lib/commonjs/generated/breez_sdk_spark.js.map +1 -1
- package/lib/module/generated/breez_sdk_spark.js +18 -5
- package/lib/module/generated/breez_sdk_spark.js.map +1 -1
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts +41 -2
- package/lib/typescript/commonjs/src/generated/breez_sdk_spark.d.ts.map +1 -1
- package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts +41 -2
- package/lib/typescript/module/src/generated/breez_sdk_spark.d.ts.map +1 -1
- package/package.json +5 -3
- package/passkey-prf-provider.d.ts +1 -0
- package/passkey-prf-provider.js +5 -0
- package/src/generated/breez_sdk_spark.ts +81 -2
package/android/src/main/kotlin/technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt
ADDED
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
package technology.breez.spark.passkey.core
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.pm.PackageManager
|
|
6
|
+
import android.content.pm.Signature
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import android.util.Log
|
|
10
|
+
import androidx.credentials.CreatePublicKeyCredentialRequest
|
|
11
|
+
import androidx.credentials.CredentialManager
|
|
12
|
+
import androidx.credentials.GetCredentialRequest
|
|
13
|
+
import androidx.credentials.GetPublicKeyCredentialOption
|
|
14
|
+
import androidx.credentials.exceptions.CreateCredentialCancellationException
|
|
15
|
+
import androidx.credentials.exceptions.CreateCredentialException
|
|
16
|
+
import androidx.credentials.exceptions.GetCredentialCancellationException
|
|
17
|
+
import androidx.credentials.exceptions.GetCredentialException
|
|
18
|
+
import androidx.credentials.exceptions.NoCredentialException
|
|
19
|
+
import androidx.credentials.exceptions.domerrors.InvalidStateError
|
|
20
|
+
import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException
|
|
21
|
+
import kotlinx.coroutines.Dispatchers
|
|
22
|
+
import kotlinx.coroutines.delay
|
|
23
|
+
import kotlinx.coroutines.sync.Mutex
|
|
24
|
+
import kotlinx.coroutines.sync.withLock
|
|
25
|
+
import kotlinx.coroutines.withContext
|
|
26
|
+
import org.json.JSONArray
|
|
27
|
+
import org.json.JSONObject
|
|
28
|
+
import java.net.HttpURLConnection
|
|
29
|
+
import java.net.URL
|
|
30
|
+
import java.net.URLEncoder
|
|
31
|
+
import java.security.MessageDigest
|
|
32
|
+
import java.security.SecureRandom
|
|
33
|
+
|
|
34
|
+
// =====================================================================
|
|
35
|
+
// !!! SOURCE-OF-TRUTH NOTICE !!!
|
|
36
|
+
//
|
|
37
|
+
// Canonical copy:
|
|
38
|
+
// crates/breez-sdk/bindings/langs/shared/android-passkey/src/main/kotlin/
|
|
39
|
+
// technology/breez/spark/passkey/core/CredentialManagerPrfCore.kt
|
|
40
|
+
//
|
|
41
|
+
// Shared into four Android artifacts via gradle `srcDirs`
|
|
42
|
+
// (bindings-android + breez-sdk-spark-kmp) and `cargo xtask
|
|
43
|
+
// sync-passkey-core` (packages/flutter + packages/react-native). Never
|
|
44
|
+
// hand-edit a copy: edit this file, run the xtask, commit the diff. CI
|
|
45
|
+
// fails if a copy drifts.
|
|
46
|
+
// =====================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Framework-agnostic helper wrapping AndroidX Credential Manager + the
|
|
50
|
+
* WebAuthn PRF extension for passkey-based seed derivation.
|
|
51
|
+
*
|
|
52
|
+
* Wrappers (UniFFI `PasskeyProvider`, Flutter MethodChannel, React Native
|
|
53
|
+
* module) delegate here and add only framework glue: error mapping,
|
|
54
|
+
* activity retrieval, call-site boilerplate. Throws
|
|
55
|
+
* [CredentialManagerPrfCoreException] for every well-known failure so
|
|
56
|
+
* wrappers can switch on [Kind] without touching Credential Manager
|
|
57
|
+
* internals.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A passkey credential from a register or sign-in ceremony.
|
|
62
|
+
* [credentialId] is always set. The remaining fields are populated on
|
|
63
|
+
* registration and null on sign-in (an assertion carries no
|
|
64
|
+
* attestation). [aaguid] is the 16-byte Authenticator Attestation GUID
|
|
65
|
+
* (provider identifier), unverified attestation: a display hint only,
|
|
66
|
+
* never a trust decision. [backupEligible] is the BE flag (can the
|
|
67
|
+
* credential sync across devices). [userId] is the core-minted WebAuthn
|
|
68
|
+
* user handle, never host-supplied. Persist [credentialId] to drive
|
|
69
|
+
* `excludeCredentials` / `allowCredentials` on later calls.
|
|
70
|
+
*/
|
|
71
|
+
public data class PasskeyCredential(
|
|
72
|
+
public val credentialId: ByteArray,
|
|
73
|
+
public val userId: ByteArray?,
|
|
74
|
+
public val aaguid: ByteArray?,
|
|
75
|
+
public val backupEligible: Boolean?,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
private const val CORE_TAG = "PasskeyPrfCore"
|
|
79
|
+
|
|
80
|
+
// =====================================================================
|
|
81
|
+
// Post-create grace
|
|
82
|
+
// =====================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A newly-registered passkey is briefly not discoverable, so the
|
|
86
|
+
* immediate post-create assertion can miss a credential that exists.
|
|
87
|
+
* [arm] opens a window in which the next derive re-asks instead of
|
|
88
|
+
* trusting that first miss.
|
|
89
|
+
*
|
|
90
|
+
* Scoped to that case only: the retry catches `NoCredentialException`,
|
|
91
|
+
* so a dropped `prf.second` is not covered and still falls through to
|
|
92
|
+
* the single-salt recover in [CredentialManagerPrfCore.deriveSeeds].
|
|
93
|
+
*
|
|
94
|
+
* The window is a ceiling, not a wait: indexing takes as long as the
|
|
95
|
+
* device takes, so a fixed sleep either returns before the credential is
|
|
96
|
+
* queryable or taxes every registration. Retrying costs nothing once the
|
|
97
|
+
* credential resolves.
|
|
98
|
+
*
|
|
99
|
+
* Named after iOS's `PostCreateGraceTracker`, which still waits the
|
|
100
|
+
* window out rather than re-asking; an instance lives inside
|
|
101
|
+
* [CredentialManagerPrfCore] so every consumer that holds onto a single
|
|
102
|
+
* core (e.g. `PasskeyProvider` for UniFFI / KMM consumers) inherits the
|
|
103
|
+
* grace without per-wrapper plumbing.
|
|
104
|
+
*/
|
|
105
|
+
public class PostCreateGraceTracker {
|
|
106
|
+
private val mutex = Mutex()
|
|
107
|
+
@Volatile private var deadlineMs: Long = 0L
|
|
108
|
+
|
|
109
|
+
public suspend fun arm(durationMs: Long = DEFAULT_DURATION_MS) {
|
|
110
|
+
mutex.withLock {
|
|
111
|
+
deadlineMs = System.currentTimeMillis() + durationMs
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Absolute time until which a post-create assertion may re-ask, or 0
|
|
117
|
+
* when no registration preceded this derive. Clears the window, so a
|
|
118
|
+
* later derive is not covered by it.
|
|
119
|
+
*/
|
|
120
|
+
public suspend fun consumeDeadline(): Long = mutex.withLock {
|
|
121
|
+
val deadline = deadlineMs
|
|
122
|
+
deadlineMs = 0L
|
|
123
|
+
deadline
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public companion object {
|
|
127
|
+
public const val DEFAULT_DURATION_MS: Long = 5_000L
|
|
128
|
+
|
|
129
|
+
/** Gap between re-asks; each miss is a fast local no-UI failure. */
|
|
130
|
+
public const val RETRY_INTERVAL_MS: Long = 100L
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Platform PRF engine: holds the relying-party identity for one
|
|
136
|
+
* provider's lifetime, exposing `deriveSeeds`, `register`,
|
|
137
|
+
* `checkDomainAssociation`, and `isSupported`. Per-call methods take
|
|
138
|
+
* only per-ceremony arguments; everything else is fixed at
|
|
139
|
+
* construction. Each consumer maps the typed
|
|
140
|
+
* [CredentialManagerPrfCoreException] onto its own error surface.
|
|
141
|
+
*
|
|
142
|
+
* @param activityProvider Resolves the current top Activity lazily on
|
|
143
|
+
* each call, so a stale instance is never held across rotation.
|
|
144
|
+
* @param graceTracker Post-create grace state. Defaults to a fresh
|
|
145
|
+
* tracker per core; consumers that pool cores across the same wrapper
|
|
146
|
+
* instance can share one tracker explicitly.
|
|
147
|
+
* @param postCreateGraceMs How long [register] arms the grace for.
|
|
148
|
+
* Defaults to [PostCreateGraceTracker.DEFAULT_DURATION_MS].
|
|
149
|
+
*/
|
|
150
|
+
public class CredentialManagerPrfCore(
|
|
151
|
+
private val rpId: String,
|
|
152
|
+
private val rpName: String,
|
|
153
|
+
private val userName: String,
|
|
154
|
+
private val userDisplayName: String,
|
|
155
|
+
private val activityProvider: () -> Activity,
|
|
156
|
+
private val graceTracker: PostCreateGraceTracker = PostCreateGraceTracker(),
|
|
157
|
+
private val postCreateGraceMs: Long = PostCreateGraceTracker.DEFAULT_DURATION_MS,
|
|
158
|
+
) {
|
|
159
|
+
|
|
160
|
+
public companion object {
|
|
161
|
+
/** Default Relying Party ID for cross-platform credential sharing. */
|
|
162
|
+
public const val DEFAULT_RP_ID: String = "keys.breez.technology"
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `true` if the OS version could support passkey PRF (API 28+).
|
|
166
|
+
* Checks the platform version only, not whether a credential
|
|
167
|
+
* provider is installed or biometrics are enrolled.
|
|
168
|
+
*/
|
|
169
|
+
public fun isSupported(): Boolean =
|
|
170
|
+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
|
171
|
+
|
|
172
|
+
/** Lazily initialised; first-use entropy gathering can dominate the cold path. */
|
|
173
|
+
private val secureRandom: SecureRandom by lazy { SecureRandom() }
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Cached `CredentialManager`, held process-wide against the
|
|
177
|
+
* application context (lifecycle-safe across rotation) so per-call
|
|
178
|
+
* core instances don't each re-allocate it.
|
|
179
|
+
*/
|
|
180
|
+
@Volatile
|
|
181
|
+
private var cachedCredentialManager: CredentialManager? = null
|
|
182
|
+
|
|
183
|
+
private fun credentialManager(activity: Activity): CredentialManager =
|
|
184
|
+
cachedCredentialManager ?: synchronized(this) {
|
|
185
|
+
cachedCredentialManager ?: CredentialManager.create(activity.applicationContext).also {
|
|
186
|
+
cachedCredentialManager = it
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun encodeBase64Url(bytes: ByteArray): String =
|
|
191
|
+
Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
192
|
+
|
|
193
|
+
private fun decodeBase64Url(s: String): ByteArray =
|
|
194
|
+
Base64.decode(s, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Decode URL-safe base64 from a provider response, logging +
|
|
198
|
+
* returning null on malformed input (a provider/protocol fault).
|
|
199
|
+
*/
|
|
200
|
+
private fun decodeBase64UrlOrNull(s: String, what: String): ByteArray? =
|
|
201
|
+
try {
|
|
202
|
+
decodeBase64Url(s)
|
|
203
|
+
} catch (e: IllegalArgumentException) {
|
|
204
|
+
Log.w(CORE_TAG, "Malformed base64url in provider response ($what)", e)
|
|
205
|
+
null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private fun randomBase64Url(byteCount: Int): String {
|
|
209
|
+
val bytes = ByteArray(byteCount)
|
|
210
|
+
secureRandom.nextBytes(bytes)
|
|
211
|
+
return encodeBase64Url(bytes)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Derive one 32-byte PRF output per salt in as few authenticator
|
|
217
|
+
* ceremonies as the platform supports: salts are walked in pairs
|
|
218
|
+
* (one dual-salt assertion each via `prf.eval.first`/`.second`),
|
|
219
|
+
* and an authenticator that drops `second` is recovered with a
|
|
220
|
+
* single-salt re-assert. When no credential exists yet and
|
|
221
|
+
* [autoRegister] is set, the first miss registers a passkey and
|
|
222
|
+
* retries. Output ordering matches input ordering.
|
|
223
|
+
*
|
|
224
|
+
* Prompt count for the common 2-salt setup: 1 on a conformant
|
|
225
|
+
* authenticator. Worst case is 3 (assert-miss, register, then a
|
|
226
|
+
* dual-assert that drops `prf.second` plus a single-salt recover) on
|
|
227
|
+
* a provider that both lacks a credential and drops `second`. Register
|
|
228
|
+
* runs at most once per call, so prompts never grow unbounded.
|
|
229
|
+
*/
|
|
230
|
+
public suspend fun deriveSeeds(
|
|
231
|
+
salts: List<String>,
|
|
232
|
+
autoRegister: Boolean = true,
|
|
233
|
+
allowCredentials: List<ByteArray> = emptyList(),
|
|
234
|
+
preferImmediatelyAvailableCredentials: Boolean = true,
|
|
235
|
+
): PrfDerivation = withContext(Dispatchers.Main) {
|
|
236
|
+
// Re-ask, rather than sleep, while a just-created credential is
|
|
237
|
+
// still being indexed (see grace tracker). 0 when no register
|
|
238
|
+
// preceded this call, which disables the retry entirely.
|
|
239
|
+
val indexRetryUntilMs = graceTracker.consumeDeadline()
|
|
240
|
+
// Pinned to the first asserted credential after the first chunk so
|
|
241
|
+
// every salt in this call derives from one passkey.
|
|
242
|
+
var allow = allowCredentials
|
|
243
|
+
if (salts.isEmpty()) return@withContext PrfDerivation(emptyList(), null)
|
|
244
|
+
|
|
245
|
+
// One assertion for 1-2 salts, registering + retrying once on no
|
|
246
|
+
// credential. Returns one output per salt the authenticator
|
|
247
|
+
// evaluated (a dropped `second` yields one) plus the asserted
|
|
248
|
+
// credential ID. After the first chunk the caller pins `allow` to
|
|
249
|
+
// it, so every chunk resolves to the same credential.
|
|
250
|
+
suspend fun assertChunk(chunk: List<String>): Pair<List<ByteArray>, ByteArray?> =
|
|
251
|
+
try {
|
|
252
|
+
assertPrfAwaitingIndex(
|
|
253
|
+
chunk,
|
|
254
|
+
allow,
|
|
255
|
+
preferImmediatelyAvailableCredentials,
|
|
256
|
+
indexRetryUntilMs,
|
|
257
|
+
)
|
|
258
|
+
} catch (e: NoCredentialException) {
|
|
259
|
+
if (!autoRegister) {
|
|
260
|
+
throw CredentialManagerPrfCoreException(
|
|
261
|
+
Kind.CredentialNotFound,
|
|
262
|
+
e.message ?: "",
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
register()
|
|
266
|
+
// Retry once. A second miss (e.g. user deleted the pinned
|
|
267
|
+
// credential in Settings) escapes as CredentialNotFound for
|
|
268
|
+
// hosts to treat as deletion recovery.
|
|
269
|
+
assertPrf(chunk, allow, preferImmediatelyAvailableCredentials)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
val startedAtMs = System.currentTimeMillis()
|
|
273
|
+
try {
|
|
274
|
+
val output = ArrayList<ByteArray>(salts.size)
|
|
275
|
+
// Asserted credential ID, returned so the binding layer can
|
|
276
|
+
// surface it on `SignInResponse.credential_id`.
|
|
277
|
+
var observedCredentialId: ByteArray? = null
|
|
278
|
+
var idx = 0
|
|
279
|
+
while (idx < salts.size) {
|
|
280
|
+
if (idx + 1 < salts.size) {
|
|
281
|
+
val (outputs, credId) = assertChunk(listOf(salts[idx], salts[idx + 1]))
|
|
282
|
+
observedCredentialId = credId
|
|
283
|
+
// Pin every later assertion in this call to the credential
|
|
284
|
+
// the first one resolved to, so all salts derive from one
|
|
285
|
+
// passkey even when a chunk splits (dropped `second`, 3+ salts).
|
|
286
|
+
credId?.let { allow = listOf(it) }
|
|
287
|
+
output.add(outputs[0])
|
|
288
|
+
if (outputs.size > 1) {
|
|
289
|
+
output.add(outputs[1])
|
|
290
|
+
} else {
|
|
291
|
+
// Authenticator dropped `second`: single-salt recover,
|
|
292
|
+
// pinned to the same credential as the first output.
|
|
293
|
+
val (recovered, _) = assertChunk(listOf(salts[idx + 1]))
|
|
294
|
+
output.add(recovered[0])
|
|
295
|
+
}
|
|
296
|
+
idx += 2
|
|
297
|
+
} else {
|
|
298
|
+
val (single, credId) = assertChunk(listOf(salts[idx]))
|
|
299
|
+
observedCredentialId = credId
|
|
300
|
+
credId?.let { allow = listOf(it) }
|
|
301
|
+
output.add(single[0])
|
|
302
|
+
idx += 1
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
PrfDerivation(output, observedCredentialId)
|
|
306
|
+
} catch (e: CredentialManagerPrfCoreException) {
|
|
307
|
+
throw e
|
|
308
|
+
} catch (e: Exception) {
|
|
309
|
+
throw e.toCoreException(System.currentTimeMillis() - startedAtMs)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Verify the app is listed by Google's Digital Asset Links API for
|
|
315
|
+
* [rpId] with `get_login_creds` permission, queried up front because
|
|
316
|
+
* Credential Manager otherwise surfaces a stale or missing
|
|
317
|
+
* assetlinks.json as an opaque `CredentialNotFound` / "cannot be
|
|
318
|
+
* validated" error indistinguishable from "no credential found":
|
|
319
|
+
*
|
|
320
|
+
* `GET https://digitalassetlinks.googleapis.com/v1/statements:list`
|
|
321
|
+
* `?source.web.site=https://<rpId>`
|
|
322
|
+
* `&relation=delegate_permission/common.get_login_creds`
|
|
323
|
+
*
|
|
324
|
+
* Requires an `android_app` statement matching this app's package name
|
|
325
|
+
* AND signing-cert SHA-256: a package-only match would accept a MITM'd
|
|
326
|
+
* package signed by a different key.
|
|
327
|
+
*
|
|
328
|
+
* Returns `Associated` on a match, `NotAssociated` when the endpoint
|
|
329
|
+
* is reachable but no statement matches, `Skipped` on any
|
|
330
|
+
* network/timeout/non-200/parse failure (caller proceeds anyway).
|
|
331
|
+
*
|
|
332
|
+
* @param connectTimeoutMs HTTP connect timeout. Default 3000ms.
|
|
333
|
+
* @param readTimeoutMs HTTP read timeout. Default 3000ms.
|
|
334
|
+
*/
|
|
335
|
+
public suspend fun checkDomainAssociation(
|
|
336
|
+
connectTimeoutMs: Int = 3000,
|
|
337
|
+
readTimeoutMs: Int = 3000,
|
|
338
|
+
): DomainAssociationResult = withContext(Dispatchers.IO) {
|
|
339
|
+
val context = activityProvider().applicationContext
|
|
340
|
+
val packageName = context.packageName
|
|
341
|
+
val signingCertSha256 = try {
|
|
342
|
+
computeSigningCertSha256(context, packageName)
|
|
343
|
+
} catch (e: Exception) {
|
|
344
|
+
return@withContext DomainAssociationResult.Skipped(
|
|
345
|
+
"Could not read app signing certificate: ${e.message}"
|
|
346
|
+
)
|
|
347
|
+
} ?: return@withContext DomainAssociationResult.Skipped(
|
|
348
|
+
"App has no signing certificate (unsigned debug build?)"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
val encodedRpId = URLEncoder.encode(rpId, "UTF-8")
|
|
352
|
+
val url = URL(
|
|
353
|
+
"https://digitalassetlinks.googleapis.com/v1/statements:list" +
|
|
354
|
+
"?source.web.site=https://$encodedRpId" +
|
|
355
|
+
"&relation=delegate_permission/common.get_login_creds"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
val responseJson = try {
|
|
359
|
+
val connection = url.openConnection() as HttpURLConnection
|
|
360
|
+
connection.connectTimeout = connectTimeoutMs
|
|
361
|
+
connection.readTimeout = readTimeoutMs
|
|
362
|
+
connection.requestMethod = "GET"
|
|
363
|
+
try {
|
|
364
|
+
if (connection.responseCode != 200) {
|
|
365
|
+
return@withContext DomainAssociationResult.Skipped(
|
|
366
|
+
"Digital Asset Links API returned HTTP ${connection.responseCode}"
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
connection.inputStream.bufferedReader().use { it.readText() }
|
|
370
|
+
} finally {
|
|
371
|
+
connection.disconnect()
|
|
372
|
+
}
|
|
373
|
+
} catch (e: Exception) {
|
|
374
|
+
return@withContext DomainAssociationResult.Skipped(
|
|
375
|
+
"Digital Asset Links API fetch failed: ${e.message}"
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
val statements = try {
|
|
380
|
+
JSONObject(responseJson).optJSONArray("statements") ?: JSONArray()
|
|
381
|
+
} catch (e: Exception) {
|
|
382
|
+
return@withContext DomainAssociationResult.Skipped(
|
|
383
|
+
"Digital Asset Links API returned unparseable JSON: ${e.message}"
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The API response format differs from the assetlinks.json FILE:
|
|
388
|
+
// it's proto3 JSON with camelCase keys and de-nests each
|
|
389
|
+
// fingerprint into its own statement (file-format snake_case keys
|
|
390
|
+
// never match). A matching statement looks like:
|
|
391
|
+
//
|
|
392
|
+
// { "target": { "androidApp": {
|
|
393
|
+
// "packageName": "technology.breez.glow",
|
|
394
|
+
// "certificate": { "sha256Fingerprint": "AA:BB:..." }
|
|
395
|
+
// } } }
|
|
396
|
+
val listedFingerprints = mutableListOf<String>()
|
|
397
|
+
for (i in 0 until statements.length()) {
|
|
398
|
+
val stmt = statements.optJSONObject(i) ?: continue
|
|
399
|
+
val target = stmt.optJSONObject("target") ?: continue
|
|
400
|
+
val androidApp = target.optJSONObject("androidApp") ?: continue
|
|
401
|
+
if (androidApp.optString("packageName") != packageName) continue
|
|
402
|
+
val fingerprint = androidApp.optJSONObject("certificate")
|
|
403
|
+
?.optString("sha256Fingerprint") ?: continue
|
|
404
|
+
if (fingerprint.isEmpty()) continue
|
|
405
|
+
listedFingerprints.add(fingerprint)
|
|
406
|
+
if (fingerprint.equals(signingCertSha256, ignoreCase = true)) {
|
|
407
|
+
return@withContext DomainAssociationResult.Associated
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
DomainAssociationResult.NotAssociated(
|
|
412
|
+
source = "Google Digital Asset Links API",
|
|
413
|
+
reason = "Package $packageName " +
|
|
414
|
+
(if (listedFingerprints.isEmpty())
|
|
415
|
+
"has no android_app statement for https://$rpId " +
|
|
416
|
+
"(relation: delegate_permission/common.get_login_creds)."
|
|
417
|
+
else
|
|
418
|
+
"is listed for https://$rpId but none of the statement " +
|
|
419
|
+
"fingerprints match this app's signing certificate " +
|
|
420
|
+
"$signingCertSha256. Listed: [${listedFingerprints.joinToString()}]."
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* SHA-256 of the app's signing certificate in colon-separated
|
|
427
|
+
* uppercase hex (the format Digital Asset Links uses). Null if the app
|
|
428
|
+
* has no signing certificate. Uses `GET_SIGNING_CERTIFICATES`, always
|
|
429
|
+
* available given the API 28+ contract.
|
|
430
|
+
*/
|
|
431
|
+
@Suppress("DEPRECATION")
|
|
432
|
+
private fun computeSigningCertSha256(context: Context, packageName: String): String? {
|
|
433
|
+
val signatures: Array<Signature>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
434
|
+
val packageInfo = context.packageManager.getPackageInfo(
|
|
435
|
+
packageName,
|
|
436
|
+
PackageManager.GET_SIGNING_CERTIFICATES,
|
|
437
|
+
)
|
|
438
|
+
// Current signer only: assetlinks.json matches the current
|
|
439
|
+
// signing cert, not past signers.
|
|
440
|
+
val signingInfo = packageInfo.signingInfo ?: return null
|
|
441
|
+
if (signingInfo.hasMultipleSigners()) {
|
|
442
|
+
signingInfo.apkContentsSigners
|
|
443
|
+
} else {
|
|
444
|
+
signingInfo.signingCertificateHistory
|
|
445
|
+
}
|
|
446
|
+
} else {
|
|
447
|
+
// Defensive only: the API 28+ contract makes this unreachable.
|
|
448
|
+
val packageInfo = context.packageManager.getPackageInfo(
|
|
449
|
+
packageName,
|
|
450
|
+
PackageManager.GET_SIGNATURES,
|
|
451
|
+
)
|
|
452
|
+
packageInfo.signatures
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
val signature = signatures?.firstOrNull() ?: return null
|
|
456
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(signature.toByteArray())
|
|
457
|
+
return digest.joinToString(":") { "%02X".format(it) }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ------------------------------------------------------------------
|
|
461
|
+
// Private
|
|
462
|
+
// ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* PRF outputs carried on a registration response, or null when the
|
|
466
|
+
* authenticator reported support without evaluating (`prf.enabled`
|
|
467
|
+
* with no `results`), which is the pre-eval-at-create behavior.
|
|
468
|
+
*/
|
|
469
|
+
private fun readRegistrationPrfResults(responseJson: JSONObject): List<ByteArray>? {
|
|
470
|
+
// Never throw: the passkey exists by now, so a decode failure that
|
|
471
|
+
// escapes fails the whole registration, and the caller's natural
|
|
472
|
+
// recovery is to register again and strand this one. Null falls
|
|
473
|
+
// back to the assertion path, which derives from the same passkey.
|
|
474
|
+
val results = responseJson
|
|
475
|
+
.optJSONObject("clientExtensionResults")
|
|
476
|
+
?.optJSONObject("prf")
|
|
477
|
+
?.optJSONObject("results")
|
|
478
|
+
?: return null
|
|
479
|
+
// Never throw: the passkey exists by now, so a decode failure that
|
|
480
|
+
// escapes fails the whole registration, and the caller's natural
|
|
481
|
+
// recovery is to register again and strand this one. Null falls
|
|
482
|
+
// back to the assertion path, which derives from the same passkey.
|
|
483
|
+
val first = results.optString("first").takeIf { it.isNotEmpty() } ?: return null
|
|
484
|
+
val out = ArrayList<ByteArray>(2)
|
|
485
|
+
out.add(decodeBase64UrlOrNull(first, "registration prf.first") ?: return null)
|
|
486
|
+
results.optString("second").takeIf { it.isNotEmpty() }?.let {
|
|
487
|
+
out.add(decodeBase64UrlOrNull(it, "registration prf.second") ?: return null)
|
|
488
|
+
}
|
|
489
|
+
return out
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* [assertPrf], re-asking until [retryUntilMs] while Credential Manager
|
|
494
|
+
* still reports no credential. A passkey that exists but is not yet
|
|
495
|
+
* queryable is indistinguishable from an absent one (both raise
|
|
496
|
+
* `NoCredentialException`), so asking again is the only way to tell
|
|
497
|
+
* them apart, and the caller has just created one.
|
|
498
|
+
*
|
|
499
|
+
* Only retries under [preferImmediatelyAvailableCredentials], where a
|
|
500
|
+
* miss is a fast local failure with no UI. Without it a miss means the
|
|
501
|
+
* user saw and dismissed a picker, which must not be re-shown.
|
|
502
|
+
*/
|
|
503
|
+
private suspend fun assertPrfAwaitingIndex(
|
|
504
|
+
salts: List<String>,
|
|
505
|
+
allowCredentials: List<ByteArray>,
|
|
506
|
+
preferImmediatelyAvailableCredentials: Boolean,
|
|
507
|
+
retryUntilMs: Long,
|
|
508
|
+
): Pair<List<ByteArray>, ByteArray?> {
|
|
509
|
+
while (true) {
|
|
510
|
+
try {
|
|
511
|
+
return assertPrf(salts, allowCredentials, preferImmediatelyAvailableCredentials)
|
|
512
|
+
} catch (e: NoCredentialException) {
|
|
513
|
+
if (!preferImmediatelyAvailableCredentials
|
|
514
|
+
|| System.currentTimeMillis() >= retryUntilMs
|
|
515
|
+
) {
|
|
516
|
+
throw e
|
|
517
|
+
}
|
|
518
|
+
Log.d(CORE_TAG, "Credential not indexed yet, re-asking")
|
|
519
|
+
delay(PostCreateGraceTracker.RETRY_INTERVAL_MS)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Run one assertion ceremony for [salts] (1 or 2): build the WebAuthn
|
|
526
|
+
* request (cross-device hybrid suppressed unless
|
|
527
|
+
* [preferImmediatelyAvailableCredentials] is false), evaluate PRF, and
|
|
528
|
+
* return one 32-byte output per salt the authenticator evaluated plus
|
|
529
|
+
* the asserted credential ID. A dropped `results.second` yields a
|
|
530
|
+
* single-element list.
|
|
531
|
+
*/
|
|
532
|
+
|
|
533
|
+
private suspend fun assertPrf(
|
|
534
|
+
salts: List<String>,
|
|
535
|
+
allowCredentials: List<ByteArray>,
|
|
536
|
+
preferImmediatelyAvailableCredentials: Boolean,
|
|
537
|
+
): Pair<List<ByteArray>, ByteArray?> {
|
|
538
|
+
val activity = activityProvider()
|
|
539
|
+
// JSONObject (not string interpolation) so integrator-supplied rpId
|
|
540
|
+
// is escaped, not breaking on quotes/backslashes inside Credential
|
|
541
|
+
// Manager.
|
|
542
|
+
val requestJson = JSONObject().apply {
|
|
543
|
+
put("challenge", randomBase64Url(32))
|
|
544
|
+
put("rpId", rpId)
|
|
545
|
+
put("allowCredentials", JSONArray().apply {
|
|
546
|
+
for (credId in allowCredentials) {
|
|
547
|
+
put(JSONObject().apply {
|
|
548
|
+
put("type", "public-key")
|
|
549
|
+
put("id", encodeBase64Url(credId))
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
})
|
|
553
|
+
put("userVerification", "required")
|
|
554
|
+
put("extensions", JSONObject().apply {
|
|
555
|
+
put("prf", JSONObject().apply {
|
|
556
|
+
put("eval", JSONObject().apply {
|
|
557
|
+
put("first", encodeBase64Url(salts[0].toByteArray(Charsets.UTF_8)))
|
|
558
|
+
if (salts.size > 1) {
|
|
559
|
+
put("second", encodeBase64Url(salts[1].toByteArray(Charsets.UTF_8)))
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
})
|
|
564
|
+
}.toString()
|
|
565
|
+
|
|
566
|
+
// `preferImmediatelyAvailableCredentials = true` suppresses the
|
|
567
|
+
// cross-device QR sheet so a missing local credential surfaces
|
|
568
|
+
// as NoCredentialException; `false` opts back into the picker.
|
|
569
|
+
val request = GetCredentialRequest.Builder()
|
|
570
|
+
.addCredentialOption(GetPublicKeyCredentialOption(requestJson))
|
|
571
|
+
.setPreferImmediatelyAvailableCredentials(preferImmediatelyAvailableCredentials)
|
|
572
|
+
.build()
|
|
573
|
+
val response = credentialManager(activity).getCredential(activity, request)
|
|
574
|
+
|
|
575
|
+
val authResponseJson = response.credential.data.getString(
|
|
576
|
+
"androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON",
|
|
577
|
+
) ?: throw CredentialManagerPrfCoreException(
|
|
578
|
+
Kind.AuthenticationFailed, "No credential response",
|
|
579
|
+
)
|
|
580
|
+
val responseJson = JSONObject(authResponseJson)
|
|
581
|
+
|
|
582
|
+
// Asserted credential ID, returned inline for the binding layer.
|
|
583
|
+
val credentialId = responseJson.optString("rawId", "").takeIf { it.isNotEmpty() }
|
|
584
|
+
?.let { decodeBase64UrlOrNull(it, "assertion rawId") }
|
|
585
|
+
|
|
586
|
+
val extensions = responseJson.optJSONObject("clientExtensionResults")
|
|
587
|
+
?: throw CredentialManagerPrfCoreException(Kind.PrfNotSupported)
|
|
588
|
+
val prf = extensions.optJSONObject("prf")
|
|
589
|
+
?: throw CredentialManagerPrfCoreException(Kind.PrfNotSupported)
|
|
590
|
+
val results = prf.optJSONObject("results")
|
|
591
|
+
?: throw CredentialManagerPrfCoreException(Kind.PrfEvaluationFailed, "no results")
|
|
592
|
+
|
|
593
|
+
val first = results.optString("first")
|
|
594
|
+
if (first.isNullOrEmpty()) {
|
|
595
|
+
throw CredentialManagerPrfCoreException(Kind.PrfEvaluationFailed, "empty result")
|
|
596
|
+
}
|
|
597
|
+
val out = ArrayList<ByteArray>(salts.size)
|
|
598
|
+
out.add(decodeBase64Url(first))
|
|
599
|
+
// Older Credential Manager implementations silently drop
|
|
600
|
+
// saltInput2; omit it so the caller re-asserts single-salt.
|
|
601
|
+
if (salts.size > 1) {
|
|
602
|
+
results.optString("second", "").takeIf { it.isNotEmpty() }
|
|
603
|
+
?.let { decodeBase64UrlOrNull(it, "prf.results.second") }
|
|
604
|
+
?.let { out.add(it) }
|
|
605
|
+
}
|
|
606
|
+
return Pair(out, credentialId)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Register a new passkey (one platform prompt, no seed derivation).
|
|
611
|
+
* [excludeCredentials] is passed straight through so the platform
|
|
612
|
+
* refuses to register a credential already on the device (raising
|
|
613
|
+
* `CredentialAlreadyExists`, which callers route to sign-in). Returns
|
|
614
|
+
* the credential ID plus AAGUID / backup-eligibility from the
|
|
615
|
+
* attestation (null when unparseable).
|
|
616
|
+
*/
|
|
617
|
+
public suspend fun register(
|
|
618
|
+
excludeCredentials: List<ByteArray> = emptyList(),
|
|
619
|
+
salts: List<String> = emptyList(),
|
|
620
|
+
): PasskeyRegistration = withContext(Dispatchers.Main) {
|
|
621
|
+
val startedAtMs = System.currentTimeMillis()
|
|
622
|
+
try {
|
|
623
|
+
val activity = activityProvider()
|
|
624
|
+
// Mint a fresh random 16-byte user handle per call (never
|
|
625
|
+
// host-supplied); the JSON base64url string and the raw
|
|
626
|
+
// PasskeyCredential.userId bytes share this buffer.
|
|
627
|
+
val userIdBytes = ByteArray(16).also { secureRandom.nextBytes(it) }
|
|
628
|
+
|
|
629
|
+
// JSONObject (not string interpolation) so integrator strings
|
|
630
|
+
// are escaped, not breaking on quotes/backslashes inside
|
|
631
|
+
// Credential Manager.
|
|
632
|
+
val requestJson = JSONObject().apply {
|
|
633
|
+
put("challenge", randomBase64Url(32))
|
|
634
|
+
put("rp", JSONObject().apply {
|
|
635
|
+
put("id", rpId)
|
|
636
|
+
put("name", rpName)
|
|
637
|
+
})
|
|
638
|
+
put("user", JSONObject().apply {
|
|
639
|
+
put("id", encodeBase64Url(userIdBytes))
|
|
640
|
+
put("name", userName)
|
|
641
|
+
put("displayName", userDisplayName)
|
|
642
|
+
})
|
|
643
|
+
put("pubKeyCredParams", JSONArray().apply {
|
|
644
|
+
put(JSONObject().apply { put("type", "public-key"); put("alg", -7) })
|
|
645
|
+
put(JSONObject().apply { put("type", "public-key"); put("alg", -257) })
|
|
646
|
+
})
|
|
647
|
+
if (excludeCredentials.isNotEmpty()) {
|
|
648
|
+
put("excludeCredentials", JSONArray().apply {
|
|
649
|
+
for (credId in excludeCredentials) {
|
|
650
|
+
put(JSONObject().apply {
|
|
651
|
+
put("type", "public-key")
|
|
652
|
+
put("id", encodeBase64Url(credId))
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
put("authenticatorSelection", JSONObject().apply {
|
|
658
|
+
put("residentKey", "required")
|
|
659
|
+
put("requireResidentKey", true)
|
|
660
|
+
put("userVerification", "required")
|
|
661
|
+
})
|
|
662
|
+
put("extensions", JSONObject().apply {
|
|
663
|
+
// Ask the create ceremony to evaluate PRF as well as
|
|
664
|
+
// report support. An authenticator that answers turns
|
|
665
|
+
// registration into a single ceremony, so no assertion
|
|
666
|
+
// has to race the credential becoming resolvable. One
|
|
667
|
+
// that ignores it returns `enabled` only, and the
|
|
668
|
+
// caller falls back to `deriveSeeds`.
|
|
669
|
+
put("prf", JSONObject().apply {
|
|
670
|
+
if (salts.isNotEmpty()) {
|
|
671
|
+
put("eval", JSONObject().apply {
|
|
672
|
+
put("first", encodeBase64Url(salts[0].toByteArray(Charsets.UTF_8)))
|
|
673
|
+
if (salts.size > 1) {
|
|
674
|
+
put(
|
|
675
|
+
"second",
|
|
676
|
+
encodeBase64Url(salts[1].toByteArray(Charsets.UTF_8)),
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
})
|
|
683
|
+
}.toString()
|
|
684
|
+
|
|
685
|
+
val response = credentialManager(activity).createCredential(
|
|
686
|
+
activity,
|
|
687
|
+
CreatePublicKeyCredentialRequest(requestJson),
|
|
688
|
+
)
|
|
689
|
+
val registrationJson = response.data.getString(
|
|
690
|
+
"androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE_JSON"
|
|
691
|
+
) ?: throw CredentialManagerPrfCoreException(
|
|
692
|
+
Kind.AuthenticationFailed, "No registration response",
|
|
693
|
+
)
|
|
694
|
+
val responseJson = JSONObject(registrationJson)
|
|
695
|
+
val rawId = responseJson.optString("rawId", "")
|
|
696
|
+
if (rawId.isEmpty()) {
|
|
697
|
+
throw CredentialManagerPrfCoreException(
|
|
698
|
+
Kind.AuthenticationFailed, "No credential ID in registration response",
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
val credentialId = decodeBase64Url(rawId)
|
|
702
|
+
var aaguid: ByteArray? = null
|
|
703
|
+
var backupEligible: Boolean? = null
|
|
704
|
+
val attestationB64 = responseJson.optJSONObject("response")?.optString("attestationObject", "") ?: ""
|
|
705
|
+
if (attestationB64.isNotEmpty()) {
|
|
706
|
+
extractRegistrationMetadata(decodeBase64Url(attestationB64))?.let { meta ->
|
|
707
|
+
aaguid = meta.first
|
|
708
|
+
backupEligible = meta.second
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Only usable as a complete set: a dropped `prf.eval.second`
|
|
712
|
+
// yields one output where the caller asked for two, and a
|
|
713
|
+
// partial derive is no derive at all.
|
|
714
|
+
val seeds = readRegistrationPrfResults(responseJson)
|
|
715
|
+
?.takeIf { it.size == salts.size }
|
|
716
|
+
// Arm the post-create grace only when a derive still has to
|
|
717
|
+
// run: the window exists for that assertion. Arming it on the
|
|
718
|
+
// inline-seeds path would leave it set for whatever derive
|
|
719
|
+
// came next, which is a different ceremony entirely.
|
|
720
|
+
if (seeds == null) {
|
|
721
|
+
graceTracker.arm(postCreateGraceMs)
|
|
722
|
+
}
|
|
723
|
+
PasskeyRegistration(
|
|
724
|
+
PasskeyCredential(credentialId, userIdBytes, aaguid, backupEligible),
|
|
725
|
+
seeds,
|
|
726
|
+
)
|
|
727
|
+
} catch (e: CredentialManagerPrfCoreException) {
|
|
728
|
+
throw e
|
|
729
|
+
} catch (e: Exception) {
|
|
730
|
+
throw e.toCoreException(System.currentTimeMillis() - startedAtMs)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Extract AAGUID + BE flag from the attestation object's authenticator
|
|
736
|
+
* data via byte-pattern search for the "authData" CBOR key. Returns
|
|
737
|
+
* null when not found or too short.
|
|
738
|
+
*
|
|
739
|
+
* authData layout when AT flag is set (always on a successful create):
|
|
740
|
+
* [32] flags (UP=0, UV=2, BE=3, BS=4, AT=6)
|
|
741
|
+
* [37..53) AAGUID (16 bytes)
|
|
742
|
+
*/
|
|
743
|
+
private fun extractRegistrationMetadata(attestation: ByteArray): Pair<ByteArray, Boolean>? {
|
|
744
|
+
// CBOR text key "authData": 0x68 = major type 3 (text) + length 8.
|
|
745
|
+
val key = byteArrayOf(0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61)
|
|
746
|
+
if (attestation.size < key.size) return null
|
|
747
|
+
var keyEnd = -1
|
|
748
|
+
for (i in 0..(attestation.size - key.size)) {
|
|
749
|
+
var match = true
|
|
750
|
+
for (j in key.indices) {
|
|
751
|
+
if (attestation[i + j] != key[j]) { match = false; break }
|
|
752
|
+
}
|
|
753
|
+
if (match) { keyEnd = i + key.size; break }
|
|
754
|
+
}
|
|
755
|
+
if (keyEnd < 0 || keyEnd >= attestation.size) return null
|
|
756
|
+
|
|
757
|
+
val header = attestation[keyEnd].toInt() and 0xff
|
|
758
|
+
if (header shr 5 != 2) return null
|
|
759
|
+
val minor = header and 0x1f
|
|
760
|
+
val length: Int
|
|
761
|
+
val dataStart: Int
|
|
762
|
+
when {
|
|
763
|
+
minor < 24 -> { length = minor; dataStart = keyEnd + 1 }
|
|
764
|
+
minor == 24 -> {
|
|
765
|
+
if (keyEnd + 1 >= attestation.size) return null
|
|
766
|
+
length = attestation[keyEnd + 1].toInt() and 0xff
|
|
767
|
+
dataStart = keyEnd + 2
|
|
768
|
+
}
|
|
769
|
+
minor == 25 -> {
|
|
770
|
+
if (keyEnd + 2 >= attestation.size) return null
|
|
771
|
+
length = ((attestation[keyEnd + 1].toInt() and 0xff) shl 8) or
|
|
772
|
+
(attestation[keyEnd + 2].toInt() and 0xff)
|
|
773
|
+
dataStart = keyEnd + 3
|
|
774
|
+
}
|
|
775
|
+
minor == 26 -> {
|
|
776
|
+
if (keyEnd + 4 >= attestation.size) return null
|
|
777
|
+
length = ((attestation[keyEnd + 1].toInt() and 0xff) shl 24) or
|
|
778
|
+
((attestation[keyEnd + 2].toInt() and 0xff) shl 16) or
|
|
779
|
+
((attestation[keyEnd + 3].toInt() and 0xff) shl 8) or
|
|
780
|
+
(attestation[keyEnd + 4].toInt() and 0xff)
|
|
781
|
+
dataStart = keyEnd + 5
|
|
782
|
+
}
|
|
783
|
+
else -> return null
|
|
784
|
+
}
|
|
785
|
+
if (dataStart + length > attestation.size || length < 53) return null
|
|
786
|
+
val flags = attestation[dataStart + 32].toInt() and 0xff
|
|
787
|
+
if (flags and 0x40 == 0) return null
|
|
788
|
+
val backupEligible = flags and 0x08 != 0
|
|
789
|
+
val aaguid = attestation.copyOfRange(dataStart + 37, dataStart + 53)
|
|
790
|
+
return Pair(aaguid, backupEligible)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Map a Credential Manager exception into the typed core exception.
|
|
795
|
+
* `elapsedMs` is the ceremony's wall-clock duration: a cancellation
|
|
796
|
+
* beyond ~55s reclassifies to [Kind.UserTimedOut] (biometric
|
|
797
|
+
* inactivity timeout) instead of [Kind.UserCancelled]. Pass `null`
|
|
798
|
+
* when timing is unknown to default to `UserCancelled`.
|
|
799
|
+
*/
|
|
800
|
+
private fun Exception.toCoreException(
|
|
801
|
+
elapsedMs: Long? = null,
|
|
802
|
+
): CredentialManagerPrfCoreException = when (this) {
|
|
803
|
+
is GetCredentialCancellationException,
|
|
804
|
+
is CreateCredentialCancellationException ->
|
|
805
|
+
CredentialManagerPrfCoreException(classifyCancellation(elapsedMs), cause = this)
|
|
806
|
+
|
|
807
|
+
is NoCredentialException ->
|
|
808
|
+
CredentialManagerPrfCoreException(
|
|
809
|
+
Kind.CredentialNotFound,
|
|
810
|
+
message ?: "No matching credential on this device",
|
|
811
|
+
this,
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
// Credential Manager wraps WebAuthn DOM errors here with the
|
|
815
|
+
// spec-level error in `domError`; an InvalidStateError is the
|
|
816
|
+
// duplicate-prevention check, routed to sign-in. Must precede the
|
|
817
|
+
// generic CreateCredentialException case (this is a subclass).
|
|
818
|
+
is CreatePublicKeyCredentialDomException ->
|
|
819
|
+
if (domError is InvalidStateError) {
|
|
820
|
+
CredentialManagerPrfCoreException(
|
|
821
|
+
Kind.CredentialAlreadyExists,
|
|
822
|
+
message ?: "Credential already registered for this RP",
|
|
823
|
+
this,
|
|
824
|
+
)
|
|
825
|
+
} else {
|
|
826
|
+
CredentialManagerPrfCoreException(
|
|
827
|
+
Kind.AuthenticationFailed,
|
|
828
|
+
"${type}: ${message ?: toString()}",
|
|
829
|
+
this,
|
|
830
|
+
)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
is GetCredentialException ->
|
|
834
|
+
CredentialManagerPrfCoreException(
|
|
835
|
+
Kind.AuthenticationFailed,
|
|
836
|
+
"${type}: ${message ?: toString()}",
|
|
837
|
+
this,
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
is CreateCredentialException ->
|
|
841
|
+
CredentialManagerPrfCoreException(
|
|
842
|
+
Kind.AuthenticationFailed,
|
|
843
|
+
"${type}: ${message ?: toString()}",
|
|
844
|
+
this,
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
else -> {
|
|
848
|
+
val raw = message ?: toString()
|
|
849
|
+
// Actionable hints for common misconfigurations.
|
|
850
|
+
val hint = when {
|
|
851
|
+
raw.contains("cannot be validated", ignoreCase = true) ->
|
|
852
|
+
"Domain verification failed. Passkeys require a physical device with " +
|
|
853
|
+
"Google Play Services and a valid /.well-known/assetlinks.json " +
|
|
854
|
+
"for the RP domain. Emulators are not supported."
|
|
855
|
+
raw.contains("not supported", ignoreCase = true) ->
|
|
856
|
+
"Passkeys require Android 9+ with Google Play Services, or Android " +
|
|
857
|
+
"14+ with a compatible Credential Manager provider."
|
|
858
|
+
else -> raw
|
|
859
|
+
}
|
|
860
|
+
CredentialManagerPrfCoreException(Kind.Generic, hint, this)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Tell a user-dismissed prompt from the biometric inactivity timeout:
|
|
866
|
+
* AndroidX surfaces both as the same `*CancellationException`, so the
|
|
867
|
+
* ceremony's elapsed time is the only signal. The prompt is torn down
|
|
868
|
+
* at ~55s+, so anything beyond that is [Kind.UserTimedOut].
|
|
869
|
+
*/
|
|
870
|
+
private fun classifyCancellation(elapsedMs: Long?): Kind {
|
|
871
|
+
if (elapsedMs != null && elapsedMs >= 55_000L) {
|
|
872
|
+
return Kind.UserTimedOut
|
|
873
|
+
}
|
|
874
|
+
return Kind.UserCancelled
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Discriminator for [CredentialManagerPrfCoreException]. */
|
|
878
|
+
public enum class Kind {
|
|
879
|
+
/** The authenticator does not support the WebAuthn PRF extension. */
|
|
880
|
+
PrfNotSupported,
|
|
881
|
+
/** The user dismissed the passkey prompt or cancelled the operation. */
|
|
882
|
+
UserCancelled,
|
|
883
|
+
/**
|
|
884
|
+
* The biometric prompt timed out without user interaction (~55s+).
|
|
885
|
+
* Distinct from [UserCancelled] (active dismissal): hosts may
|
|
886
|
+
* auto-retry or re-prompt rather than treating it as abandonment.
|
|
887
|
+
*/
|
|
888
|
+
UserTimedOut,
|
|
889
|
+
/** No credential exists for the RP and auto-registration was not attempted. */
|
|
890
|
+
CredentialNotFound,
|
|
891
|
+
/** Credential Manager reported an authentication / registration failure. */
|
|
892
|
+
AuthenticationFailed,
|
|
893
|
+
/** PRF evaluation produced an empty or malformed response. */
|
|
894
|
+
PrfEvaluationFailed,
|
|
895
|
+
/**
|
|
896
|
+
* Platform or app configuration error (e.g. missing
|
|
897
|
+
* assetlinks.json, misconfigured RP ID). Reserved for parity with
|
|
898
|
+
* the cross-platform taxonomy; Credential Manager reports these as
|
|
899
|
+
* `AuthenticationFailed`, so this kind is never emitted here.
|
|
900
|
+
*/
|
|
901
|
+
Configuration,
|
|
902
|
+
/**
|
|
903
|
+
* Registration refused because a credential in `excludeCredentials`
|
|
904
|
+
* is already on the authenticator. The duplicate-prevention check,
|
|
905
|
+
* surfaced as a typed kind so callers route to sign-in.
|
|
906
|
+
*/
|
|
907
|
+
CredentialAlreadyExists,
|
|
908
|
+
/** Any other unexpected error: message contains the details. */
|
|
909
|
+
Generic,
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Result of [CredentialManagerPrfCore.deriveSeeds]: one 32-byte PRF
|
|
915
|
+
* output per salt (input order) plus the asserted credential ID.
|
|
916
|
+
* [credentialId] is `null` when no assertion ran (empty `salts`).
|
|
917
|
+
*/
|
|
918
|
+
public data class PrfDerivation(
|
|
919
|
+
public val seeds: List<ByteArray>,
|
|
920
|
+
public val credentialId: ByteArray?,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* A created credential, plus the PRF outputs when the authenticator
|
|
925
|
+
* evaluated them during the create ceremony. `seeds` null means it did
|
|
926
|
+
* not (or returned fewer than asked for), so the caller derives through
|
|
927
|
+
* [CredentialManagerPrfCore.deriveSeeds].
|
|
928
|
+
*/
|
|
929
|
+
public data class PasskeyRegistration(
|
|
930
|
+
public val credential: PasskeyCredential,
|
|
931
|
+
public val seeds: List<ByteArray>?,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Typed exception thrown by [CredentialManagerPrfCore]. Wrappers should
|
|
936
|
+
* switch on [kind] to map to their own framework-specific error type.
|
|
937
|
+
*/
|
|
938
|
+
public class CredentialManagerPrfCoreException(
|
|
939
|
+
public val kind: CredentialManagerPrfCore.Kind,
|
|
940
|
+
message: String? = null,
|
|
941
|
+
cause: Throwable? = null,
|
|
942
|
+
) : Exception(message, cause)
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Result of a domain-association check. Mirrors the Rust
|
|
946
|
+
* `DomainAssociation` enum one-to-one so the wrapper maps it losslessly.
|
|
947
|
+
*/
|
|
948
|
+
public sealed class DomainAssociationResult {
|
|
949
|
+
public object Associated : DomainAssociationResult()
|
|
950
|
+
public data class NotAssociated(
|
|
951
|
+
public val source: String,
|
|
952
|
+
public val reason: String,
|
|
953
|
+
) : DomainAssociationResult()
|
|
954
|
+
public data class Skipped(public val reason: String) : DomainAssociationResult()
|
|
955
|
+
}
|