@le-space/orbitdb-identity-provider-webauthn-did 0.5.0 → 0.5.1
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/CHANGELOG.md +109 -0
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/keystore/encryption.js +159 -22
- package/src/keystore/provider.js +12 -3
- package/src/webauthn/provider.js +38 -3
- package/types/index.d.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.1
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The WebAuthn user handle is 64 random bytes instead of the UTF-8 of whatever
|
|
10
|
+
the caller passes as `userId`. An authenticator keeps one discoverable
|
|
11
|
+
credential per `(rp.id, user.id)` and **replaces** the previous one when both
|
|
12
|
+
match — silently, with no prompt and nothing to undo. Deriving the handle from
|
|
13
|
+
a typed name therefore meant two people registering as "anna" on a shared
|
|
14
|
+
device destroyed each other's passkey, and with it the DID and every entry
|
|
15
|
+
signed under it. The same happened to one person re-entering their usual name
|
|
16
|
+
after clearing storage. WebAuthn L2 §5.4.3 also forbids putting personally
|
|
17
|
+
identifying information in the handle, which a name or e-mail plainly is, and
|
|
18
|
+
the handle is stored in the authenticator indefinitely.
|
|
19
|
+
|
|
20
|
+
`userId` keeps its rightful place as `user.name`, the label the credential
|
|
21
|
+
picker shows. It is a label only: it identifies nothing, two credentials may
|
|
22
|
+
carry the same one, and nothing in this package looks a credential up by it.
|
|
23
|
+
Closes #45.
|
|
24
|
+
|
|
25
|
+
Nothing needs migrating. Recovery goes through discoverable credentials
|
|
26
|
+
(`readLargeBlobMetadata` calls `get()` with no `allowCredentials`), or through
|
|
27
|
+
an explicit credential ID where those are switched off — no path resolves a
|
|
28
|
+
credential by handle, so credentials registered under the old scheme keep
|
|
29
|
+
working untouched. The derived `did:key` comes from the credential's public
|
|
30
|
+
key and does not change.
|
|
31
|
+
|
|
32
|
+
Consumers should expect a behavioural difference: re-registering under a name
|
|
33
|
+
that was used before now **adds** a passkey instead of replacing one. That is
|
|
34
|
+
the point — a replaced passkey is data loss, a second entry is a choice — but
|
|
35
|
+
it means the picker can show several, so `user.name` and `displayName` should
|
|
36
|
+
be distinguishing enough to pick from. The new random handle is returned as
|
|
37
|
+
`credential.userHandle` (base64url); the authenticator keeps its own copy, so
|
|
38
|
+
storing it is optional.
|
|
39
|
+
|
|
40
|
+
- Actually write the secret key into `largeBlob`. The keystore record carried
|
|
41
|
+
`secretKey: sk, // Will be moved to largeBlob` — and nothing ever moved it.
|
|
42
|
+
That was the only occurrence of the field in `src/`, and
|
|
43
|
+
`storeEncryptedKeystore()` serialises a fixed whitelist that does not include
|
|
44
|
+
it, so the key was dropped at persist time. Choosing largeBlob therefore
|
|
45
|
+
produced a keystore that worked for the session that created it and could
|
|
46
|
+
never be unlocked again: on the next load `retrieveSKFromLargeBlob()` read a
|
|
47
|
+
blob nobody had written and failed with "No largeBlob data found in
|
|
48
|
+
credential".
|
|
49
|
+
|
|
50
|
+
The key never reached disk in the clear — the whitelist saw to that — but the
|
|
51
|
+
feature was inert. `writeSKToLargeBlob()` now performs the assertion that
|
|
52
|
+
stores it (a blob can only be written during an assertion, never at
|
|
53
|
+
registration, so this costs one extra prompt) and **throws unless the
|
|
54
|
+
authenticator reports `written: true`**. Failing there beats persisting a
|
|
55
|
+
record that cannot be opened.
|
|
56
|
+
|
|
57
|
+
- Carry the authenticator's own answer on the credential. `createCredential()`
|
|
58
|
+
now records `extensionSupport` — what the authenticator agreed to during the
|
|
59
|
+
ceremony — because the raw `PublicKeyCredential` does not survive past that
|
|
60
|
+
function, so the answer is read there or lost. Client support is not
|
|
61
|
+
authenticator support: measured on a macOS platform authenticator in Brave,
|
|
62
|
+
the client advertises `hmacCreateSecret` while the authenticator refuses it
|
|
63
|
+
and PRF works. Reading only the client is what left the demo offering
|
|
64
|
+
hmac-secret and then failing at the ceremony, which is the failure originally
|
|
65
|
+
reported in #9.
|
|
66
|
+
|
|
67
|
+
The encrypted-keystore demo consumes it: methods the authenticator refused
|
|
68
|
+
read "Browser yes, this passkey no" rather than "Supported", become
|
|
69
|
+
unselectable, and a selection it cannot honour falls back automatically. It
|
|
70
|
+
also no longer logs `encrypted: Yes (largeBlob)` off the back of the
|
|
71
|
+
_requested_ options — that line reported intent as though it were outcome, so
|
|
72
|
+
a failed write looked identical to a successful one. It now names the request
|
|
73
|
+
and the authenticator's capability separately, so a mismatch is visible.
|
|
74
|
+
|
|
75
|
+
- Detect WebAuthn extensions by asking the browser instead of inspecting a
|
|
76
|
+
prototype. `checkExtensionSupport()` tested
|
|
77
|
+
`'largeBlob' in PublicKeyCredential.prototype` — but extensions are never
|
|
78
|
+
properties of that interface, they arrive through
|
|
79
|
+
`getClientExtensionResults()`. The test therefore returned `false` in every
|
|
80
|
+
browser ever shipped, including those with complete support, and `hmacSecret`
|
|
81
|
+
was hard-coded `false` beside it. Both together meant the encrypted-keystore
|
|
82
|
+
demo disabled its own headline feature everywhere and could not be switched
|
|
83
|
+
on at all.
|
|
84
|
+
|
|
85
|
+
Measured on Chrome 148: the prototype probe answered `false` for `largeBlob`,
|
|
86
|
+
`prf` and `hmacCreateSecret` while `getClientCapabilities()` reported all
|
|
87
|
+
three `true`. Every extension name behaves the same way, so no browser could
|
|
88
|
+
ever have passed. Closes #9.
|
|
89
|
+
|
|
90
|
+
`checkExtensionSupport()` now reads `getClientCapabilities()` and reports
|
|
91
|
+
`prf` alongside the other two. It also returns `known`, distinguishing "the
|
|
92
|
+
browser says no" from "the browser cannot say" — callers must not treat the
|
|
93
|
+
second as a refusal, or they disable a feature that may well work.
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- `extensionSupportFromCredential(credential)` reads what the authenticator
|
|
98
|
+
actually agreed to, from the registration response. This is the authoritative
|
|
99
|
+
answer and the one worth persisting: a browser may support `largeBlob` while
|
|
100
|
+
the security key in front of it does not, and only the ceremony settles that.
|
|
101
|
+
`addLargeBlobToCredentialOptions()` takes an optional support level for the
|
|
102
|
+
same reason — it stays `'required'` by default, because under `'preferred'`
|
|
103
|
+
an unsupported authenticator yields a credential whose secret was silently
|
|
104
|
+
never written.
|
|
105
|
+
|
|
106
|
+
### Changed
|
|
107
|
+
|
|
108
|
+
- The encrypted-keystore demo offers PRF as a keystore encryption method and
|
|
109
|
+
prefers it, matching the library's own default since 0.4.0. It had hard-coded
|
|
110
|
+
`largeBlob`, so it overrode that default and never exercised PRF at all.
|
|
111
|
+
Methods the browser cannot vouch for are now labelled "Unknown" rather than
|
|
112
|
+
"Not supported", and stay selectable.
|
|
113
|
+
|
|
5
114
|
## 0.5.0
|
|
6
115
|
|
|
7
116
|
### Breaking
|
package/README.md
CHANGED
|
@@ -290,6 +290,27 @@ Mermaid sequences for scripts:
|
|
|
290
290
|
- `SECURITY.md`
|
|
291
291
|
- `CODE_OF_CONDUCT.md`
|
|
292
292
|
|
|
293
|
+
## Cross-Project Verification
|
|
294
|
+
|
|
295
|
+
This repo's own suite covers the units and runs two OrbitDB peers in-process. The strongest evidence that a release actually works, though, comes from outside it: the replication mode matrix in [`orbitdb-relay`](https://github.com/NiKrause/orbitdb-relay), at `mocha/relay-replication-mode-matrix.mjs`.
|
|
296
|
+
|
|
297
|
+
It drives six alice/bob pairings through a real relay:
|
|
298
|
+
|
|
299
|
+
| Mode | Pairing |
|
|
300
|
+
| --------------------------------------------- | ------------------------------------------ |
|
|
301
|
+
| `alice-worker-bob-hardware-ed25519` | worker keystore ↔ hardware Ed25519 |
|
|
302
|
+
| `alice-worker-bob-hardware-p256` | worker keystore ↔ hardware P-256 |
|
|
303
|
+
| `alice-hardware-ed25519-bob-hardware-p256` | the two hardware curves against each other |
|
|
304
|
+
| `alice-worker-ed25519-bob-worker-ed25519` | worker keystore, both sides |
|
|
305
|
+
| `alice-hardware-ed25519-bob-hardware-ed25519` | hardware Ed25519, both sides |
|
|
306
|
+
| `alice-hardware-p256-bob-hardware-p256` | hardware P-256, both sides |
|
|
307
|
+
|
|
308
|
+
Three things make it worth more than an in-repo test. It runs **out of process and across repos**, against the published package rather than the working tree. It exercises **identity verification between two distinct peers** — different keystores, different identity documents — instead of one instance talking to itself. And the relay **verifies identities itself**, so a third independent verifier sits in the path.
|
|
309
|
+
|
|
310
|
+
That is exactly the surface a change to the signing format breaks. The 0.5.0 signing-context change was validated here before the consumers moved: all six pairings replicate under the new format.
|
|
311
|
+
|
|
312
|
+
If you change anything that touches signing, identity documents or verification, run that matrix — not just this repo's tests.
|
|
313
|
+
|
|
293
314
|
## Identity Recovery Summary
|
|
294
315
|
|
|
295
316
|
Current identity recovery behavior in this repo:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-identity-provider-webauthn-did",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "WebAuthn-based DID identity provider for OrbitDB for hardware-secured wallets and biometric Passkey authentication",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
package/src/index.js
CHANGED
|
@@ -277,6 +277,7 @@ export {
|
|
|
277
277
|
decryptWithAESGCM,
|
|
278
278
|
addLargeBlobToCredentialOptions,
|
|
279
279
|
addPRFToCredentialOptions,
|
|
280
|
+
writeSKToLargeBlob,
|
|
280
281
|
retrieveSKFromLargeBlob,
|
|
281
282
|
addHmacSecretToCredentialOptions,
|
|
282
283
|
wrapSKWithPRF,
|
|
@@ -287,6 +288,7 @@ export {
|
|
|
287
288
|
loadEncryptedKeystore,
|
|
288
289
|
clearEncryptedKeystore,
|
|
289
290
|
checkExtensionSupport,
|
|
291
|
+
extensionSupportFromCredential,
|
|
290
292
|
} from './keystore/encryption.js';
|
|
291
293
|
|
|
292
294
|
export default {
|
|
@@ -150,19 +150,37 @@ export async function decryptWithAESGCM(ciphertext, sk, iv) {
|
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
152
|
* Store secret key in WebAuthn credential using largeBlob extension
|
|
153
|
+
*
|
|
154
|
+
* Defaults to `support: 'required'` so an authenticator that cannot hold a
|
|
155
|
+
* blob fails the ceremony outright. That is the safe default here: under
|
|
156
|
+
* `'preferred'` the credential is created successfully and the secret is
|
|
157
|
+
* simply never written, which loses the keystore silently.
|
|
158
|
+
*
|
|
159
|
+
* Pass `'preferred'` only when the caller reads the outcome back with
|
|
160
|
+
* {@link extensionSupportFromCredential} and handles an unsupported
|
|
161
|
+
* authenticator itself — probing for capability, for instance.
|
|
162
|
+
*
|
|
153
163
|
* @param {Object} credentialOptions - WebAuthn credential creation options
|
|
154
164
|
* @param {Uint8Array} sk - Secret key to store
|
|
155
|
-
* @
|
|
165
|
+
* @param {'required'|'preferred'} [support] - largeBlob support level
|
|
166
|
+
* @returns {Object} Enhanced credential options with largeBlob
|
|
156
167
|
*/
|
|
157
|
-
export function addLargeBlobToCredentialOptions(
|
|
158
|
-
|
|
168
|
+
export function addLargeBlobToCredentialOptions(
|
|
169
|
+
credentialOptions,
|
|
170
|
+
sk,
|
|
171
|
+
support = 'required'
|
|
172
|
+
) {
|
|
173
|
+
log(
|
|
174
|
+
'Adding largeBlob extension to credential options (support: %s)',
|
|
175
|
+
support
|
|
176
|
+
);
|
|
159
177
|
|
|
160
178
|
return {
|
|
161
179
|
...credentialOptions,
|
|
162
180
|
extensions: {
|
|
163
181
|
...credentialOptions.extensions,
|
|
164
182
|
largeBlob: {
|
|
165
|
-
support
|
|
183
|
+
support,
|
|
166
184
|
write: sk,
|
|
167
185
|
},
|
|
168
186
|
},
|
|
@@ -195,6 +213,67 @@ export function addPRFToCredentialOptions(
|
|
|
195
213
|
};
|
|
196
214
|
}
|
|
197
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Write the secret key into the credential's largeBlob.
|
|
218
|
+
*
|
|
219
|
+
* A blob can only be written during an assertion, never at registration — so
|
|
220
|
+
* this costs one extra WebAuthn prompt after the credential exists. That is
|
|
221
|
+
* why `addLargeBlobToCredentialOptions()` alone does not persist anything.
|
|
222
|
+
*
|
|
223
|
+
* Throws unless the authenticator reports `written: true`. Silence here would
|
|
224
|
+
* leave a keystore whose key exists only in memory: it works for the current
|
|
225
|
+
* session and can never be unlocked again, because
|
|
226
|
+
* {@link retrieveSKFromLargeBlob} would read a blob nobody wrote.
|
|
227
|
+
*
|
|
228
|
+
* @param {Uint8Array} credentialId - WebAuthn credential ID (raw bytes).
|
|
229
|
+
* @param {Uint8Array} sk - Secret key to store.
|
|
230
|
+
* @param {string} rpId - Relying party ID (domain).
|
|
231
|
+
* @returns {Promise<void>}
|
|
232
|
+
*/
|
|
233
|
+
export async function writeSKToLargeBlob(credentialId, sk, rpId) {
|
|
234
|
+
log('Writing secret key to largeBlob');
|
|
235
|
+
|
|
236
|
+
let assertion;
|
|
237
|
+
try {
|
|
238
|
+
assertion = await navigator.credentials.get(
|
|
239
|
+
buildCredentialRequestOptions({
|
|
240
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
241
|
+
credentialId,
|
|
242
|
+
rpId,
|
|
243
|
+
userVerification: 'required',
|
|
244
|
+
extensions: {
|
|
245
|
+
largeBlob: {
|
|
246
|
+
write: sk,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
log.error('largeBlob write assertion failed: %s', error.message);
|
|
253
|
+
throw new KeystoreEncryptionError(
|
|
254
|
+
`Failed to write secret key to largeBlob: ${error.message}`,
|
|
255
|
+
{ cause: error }
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The authenticator answers whether it took the blob. Accepting the
|
|
260
|
+
// assertion without reading this is how a write that never happened passes
|
|
261
|
+
// for a success.
|
|
262
|
+
const written = assertion?.getClientExtensionResults?.()?.largeBlob?.written;
|
|
263
|
+
|
|
264
|
+
if (written !== true) {
|
|
265
|
+
log.error(
|
|
266
|
+
'Authenticator did not write the largeBlob (written: %o)',
|
|
267
|
+
written
|
|
268
|
+
);
|
|
269
|
+
throw new KeystoreEncryptionError(
|
|
270
|
+
'Authenticator did not store the secret key in largeBlob'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log('Secret key written to largeBlob');
|
|
275
|
+
}
|
|
276
|
+
|
|
198
277
|
/**
|
|
199
278
|
* Retrieve secret key from WebAuthn credential using largeBlob extension
|
|
200
279
|
* @param {Uint8Array} credentialId - WebAuthn credential ID
|
|
@@ -620,39 +699,95 @@ export async function clearEncryptedKeystore(credentialId) {
|
|
|
620
699
|
}
|
|
621
700
|
|
|
622
701
|
/**
|
|
623
|
-
*
|
|
624
|
-
*
|
|
702
|
+
* Which WebAuthn extensions is this *client* willing to negotiate?
|
|
703
|
+
*
|
|
704
|
+
* Answers one question only: would the browser pass the extension through. It
|
|
705
|
+
* cannot tell you whether the authenticator behind it will honour the request —
|
|
706
|
+
* that is settled during a ceremony, by
|
|
707
|
+
* {@link extensionSupportFromCredential}, and belongs to the credential rather
|
|
708
|
+
* than to the browser.
|
|
709
|
+
*
|
|
710
|
+
* `known: false` means the browser is too old to say. That is **not** the same
|
|
711
|
+
* as unsupported: callers should attempt the ceremony and let its result
|
|
712
|
+
* decide, rather than disabling the feature outright.
|
|
713
|
+
*
|
|
714
|
+
* @returns {Promise<{largeBlob: boolean, prf: boolean, hmacSecret: boolean, known: boolean}>}
|
|
625
715
|
*/
|
|
626
716
|
export async function checkExtensionSupport() {
|
|
627
717
|
const support = {
|
|
628
718
|
largeBlob: false,
|
|
719
|
+
prf: false,
|
|
629
720
|
hmacSecret: false,
|
|
721
|
+
known: false,
|
|
630
722
|
};
|
|
631
723
|
|
|
632
|
-
if (
|
|
724
|
+
if (typeof globalThis.PublicKeyCredential !== 'function') {
|
|
633
725
|
return support;
|
|
634
726
|
}
|
|
635
727
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
728
|
+
// getClientCapabilities is the only pre-flight answer that means anything.
|
|
729
|
+
// The previous check asked `'largeBlob' in PublicKeyCredential.prototype`,
|
|
730
|
+
// which tests whether the extension is a property of the credential
|
|
731
|
+
// interface. Extensions never are — they arrive through
|
|
732
|
+
// getClientExtensionResults() — so it returned false in every browser ever
|
|
733
|
+
// shipped, including those with complete support, and silently disabled
|
|
734
|
+
// keystore encryption everywhere.
|
|
735
|
+
if (typeof PublicKeyCredential.getClientCapabilities !== 'function') {
|
|
736
|
+
log('getClientCapabilities unavailable; extension support is unknown');
|
|
737
|
+
return support;
|
|
738
|
+
}
|
|
647
739
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
740
|
+
try {
|
|
741
|
+
const capabilities = await PublicKeyCredential.getClientCapabilities();
|
|
742
|
+
return {
|
|
743
|
+
largeBlob: capabilities['extension:largeBlob'] === true,
|
|
744
|
+
prf: capabilities['extension:prf'] === true,
|
|
745
|
+
hmacSecret: capabilities['extension:hmacCreateSecret'] === true,
|
|
746
|
+
known: true,
|
|
747
|
+
};
|
|
651
748
|
} catch (error) {
|
|
652
749
|
log.error('Failed to check extension support: %s', error.message);
|
|
750
|
+
return support;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* What did the authenticator actually agree to, for this credential?
|
|
756
|
+
*
|
|
757
|
+
* The authoritative answer, and the only one worth persisting: a browser may
|
|
758
|
+
* support largeBlob while the security key in front of it does not. Read this
|
|
759
|
+
* from the registration response and store it next to the credential.
|
|
760
|
+
*
|
|
761
|
+
* Requires the ceremony to have asked with `support: 'preferred'` — see
|
|
762
|
+
* {@link addLargeBlobToCredentialOptions}. Asking with `'required'` makes
|
|
763
|
+
* creation fail outright instead of reporting back.
|
|
764
|
+
*
|
|
765
|
+
* @param {PublicKeyCredential} credential - The result of navigator.credentials.create().
|
|
766
|
+
* @returns {{largeBlob: boolean, prf: boolean, hmacSecret: boolean}}
|
|
767
|
+
*/
|
|
768
|
+
export function extensionSupportFromCredential(credential) {
|
|
769
|
+
const support = { largeBlob: false, prf: false, hmacSecret: false };
|
|
770
|
+
|
|
771
|
+
if (typeof credential?.getClientExtensionResults !== 'function') {
|
|
772
|
+
return support;
|
|
653
773
|
}
|
|
654
774
|
|
|
655
|
-
|
|
775
|
+
try {
|
|
776
|
+
const results = credential.getClientExtensionResults() ?? {};
|
|
777
|
+
return {
|
|
778
|
+
largeBlob: results.largeBlob?.supported === true,
|
|
779
|
+
// PRF reports either an explicit `enabled` flag or, when the ceremony
|
|
780
|
+
// evaluated a salt straight away, the results themselves.
|
|
781
|
+
prf: results.prf?.enabled === true || results.prf?.results != null,
|
|
782
|
+
hmacSecret: results.hmacCreateSecret === true,
|
|
783
|
+
};
|
|
784
|
+
} catch (error) {
|
|
785
|
+
log.error(
|
|
786
|
+
'Failed to read extension results from credential: %s',
|
|
787
|
+
error.message
|
|
788
|
+
);
|
|
789
|
+
return support;
|
|
790
|
+
}
|
|
656
791
|
}
|
|
657
792
|
|
|
658
793
|
export default {
|
|
@@ -660,6 +795,7 @@ export default {
|
|
|
660
795
|
encryptWithAESGCM,
|
|
661
796
|
decryptWithAESGCM,
|
|
662
797
|
addLargeBlobToCredentialOptions,
|
|
798
|
+
writeSKToLargeBlob,
|
|
663
799
|
retrieveSKFromLargeBlob,
|
|
664
800
|
addHmacSecretToCredentialOptions,
|
|
665
801
|
wrapSKWithHmacSecret,
|
|
@@ -668,4 +804,5 @@ export default {
|
|
|
668
804
|
loadEncryptedKeystore,
|
|
669
805
|
clearEncryptedKeystore,
|
|
670
806
|
checkExtensionSupport,
|
|
807
|
+
extensionSupportFromCredential,
|
|
671
808
|
};
|
package/src/keystore/provider.js
CHANGED
|
@@ -408,15 +408,24 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
408
408
|
} else if (
|
|
409
409
|
this.keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB
|
|
410
410
|
) {
|
|
411
|
-
//
|
|
412
|
-
//
|
|
411
|
+
// Write the key into the authenticator before recording anything.
|
|
412
|
+
// This used to be `secretKey: sk, // Will be moved to largeBlob` — and
|
|
413
|
+
// nothing ever moved it: the field was dropped at serialization, so
|
|
414
|
+
// the key survived only in memory and the keystore could never be
|
|
415
|
+
// unlocked again. Throws if the authenticator refuses, which is better
|
|
416
|
+
// than persisting a record that cannot be opened.
|
|
417
|
+
await KeystoreEncryption.writeSKToLargeBlob(
|
|
418
|
+
this.credential.rawCredentialId,
|
|
419
|
+
sk,
|
|
420
|
+
window.location.hostname
|
|
421
|
+
);
|
|
422
|
+
|
|
413
423
|
encryptedData = {
|
|
414
424
|
ciphertext,
|
|
415
425
|
iv,
|
|
416
426
|
credentialId: this.credential.credentialId,
|
|
417
427
|
publicKey: publicKeyBytes,
|
|
418
428
|
keyType,
|
|
419
|
-
secretKey: sk, // Will be moved to largeBlob
|
|
420
429
|
encryptionMethod: KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB,
|
|
421
430
|
};
|
|
422
431
|
} else if (
|
package/src/webauthn/provider.js
CHANGED
|
@@ -82,7 +82,11 @@ export class WebAuthnDIDProvider {
|
|
|
82
82
|
* Create a WebAuthn credential for OrbitDB identity
|
|
83
83
|
* This triggers biometric authentication (Face ID, Touch ID, Windows Hello, etc.)
|
|
84
84
|
* @param {Object} options - Credential options
|
|
85
|
-
* @param {string} options.userId -
|
|
85
|
+
* @param {string} options.userId - Account label shown in the credential
|
|
86
|
+
* picker (`user.name`). A label only: it does not identify the credential,
|
|
87
|
+
* two credentials may carry the same one, and it is never used to look one
|
|
88
|
+
* up. The handle the authenticator files the credential under is generated
|
|
89
|
+
* here and returned as `userHandle`.
|
|
86
90
|
* @param {string} options.displayName - Display name
|
|
87
91
|
* @param {string} options.domain - Domain/RP ID
|
|
88
92
|
* @param {boolean} options.encryptKeystore - Enable keystore encryption
|
|
@@ -117,7 +121,19 @@ export class WebAuthnDIDProvider {
|
|
|
117
121
|
|
|
118
122
|
// Generate challenge for credential creation
|
|
119
123
|
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
|
120
|
-
|
|
124
|
+
|
|
125
|
+
// The user handle is an opaque key, not a label. An authenticator stores
|
|
126
|
+
// one discoverable credential per (rp.id, user.id) pair and *replaces* the
|
|
127
|
+
// previous one when both match — silently, with no prompt and nothing to
|
|
128
|
+
// undo. Deriving the handle from a typed name therefore meant two people
|
|
129
|
+
// registering as "anna" on a shared device destroyed each other's passkey,
|
|
130
|
+
// and with it the DID and everything written under it (#45).
|
|
131
|
+
//
|
|
132
|
+
// 64 random bytes, as WebAuthn L2 §5.4.3 recommends. The same section
|
|
133
|
+
// forbids putting personally identifying information here, which a typed
|
|
134
|
+
// name or e-mail address plainly is. The name keeps its rightful place in
|
|
135
|
+
// `user.name` below, where the credential picker shows it.
|
|
136
|
+
const userHandle = crypto.getRandomValues(new Uint8Array(64));
|
|
121
137
|
|
|
122
138
|
webauthnLog('Calling navigator.credentials.create() for user: %s', userId);
|
|
123
139
|
|
|
@@ -130,7 +146,7 @@ export class WebAuthnDIDProvider {
|
|
|
130
146
|
id: domain,
|
|
131
147
|
},
|
|
132
148
|
user: {
|
|
133
|
-
id:
|
|
149
|
+
id: userHandle,
|
|
134
150
|
name: userId,
|
|
135
151
|
displayName,
|
|
136
152
|
},
|
|
@@ -228,6 +244,17 @@ export class WebAuthnDIDProvider {
|
|
|
228
244
|
hasY: !!publicKey.y,
|
|
229
245
|
});
|
|
230
246
|
|
|
247
|
+
// What the authenticator actually agreed to, as opposed to what the
|
|
248
|
+
// browser said it could negotiate. Only the ceremony settles this, and
|
|
249
|
+
// the raw PublicKeyCredential does not survive past this function — so
|
|
250
|
+
// read it here or lose the answer. Callers that offer a choice of
|
|
251
|
+
// keystore encryption method need it: a client can support largeBlob
|
|
252
|
+
// while the key in front of it does not.
|
|
253
|
+
const extensionSupport =
|
|
254
|
+
KeystoreEncryption.extensionSupportFromCredential(credential);
|
|
255
|
+
|
|
256
|
+
webauthnLog('Authenticator extension support: %o', extensionSupport);
|
|
257
|
+
|
|
231
258
|
const result = {
|
|
232
259
|
credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(
|
|
233
260
|
credential.rawId
|
|
@@ -236,10 +263,18 @@ export class WebAuthnDIDProvider {
|
|
|
236
263
|
publicKey,
|
|
237
264
|
userId,
|
|
238
265
|
displayName,
|
|
266
|
+
// Nothing in this package looks a credential up by handle — recovery
|
|
267
|
+
// goes through discoverable credentials, or through an explicit
|
|
268
|
+
// credential ID when those are switched off. It is surfaced anyway
|
|
269
|
+
// because it is the only copy the caller will ever see, and a flow
|
|
270
|
+
// that one day passes `allowCredentials` needs it. Decode with
|
|
271
|
+
// `WebAuthnDIDProvider.base64urlToArrayBuffer()`.
|
|
272
|
+
userHandle: WebAuthnDIDProvider.arrayBufferToBase64url(userHandle),
|
|
239
273
|
attestationObject: new Uint8Array(
|
|
240
274
|
credential.response.attestationObject
|
|
241
275
|
),
|
|
242
276
|
prfInput: prfInput || undefined,
|
|
277
|
+
extensionSupport,
|
|
243
278
|
};
|
|
244
279
|
|
|
245
280
|
webauthnLog('Credential creation completed successfully');
|
package/types/index.d.ts
CHANGED
|
@@ -91,6 +91,8 @@ export interface WebAuthnCredentialInfo {
|
|
|
91
91
|
publicKey: WebAuthnPublicKey | Uint8Array;
|
|
92
92
|
userId?: string;
|
|
93
93
|
displayName?: string;
|
|
94
|
+
/** Random 64-byte WebAuthn user handle, base64url. Not derived from userId. */
|
|
95
|
+
userHandle?: string;
|
|
94
96
|
attestationObject?: Uint8Array;
|
|
95
97
|
prfInput?: Uint8Array;
|
|
96
98
|
did?: string;
|
|
@@ -99,6 +101,7 @@ export interface WebAuthnCredentialInfo {
|
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export interface CreateCredentialOptions {
|
|
104
|
+
/** Account label for the credential picker. Not an identifier. */
|
|
102
105
|
userId?: string;
|
|
103
106
|
displayName?: string;
|
|
104
107
|
domain?: string;
|