@le-space/orbitdb-identity-provider-webauthn-did 0.4.2 → 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 +157 -0
- package/README.md +78 -0
- package/package.json +3 -3
- package/src/index.js +2 -0
- package/src/keystore/encryption.js +159 -22
- package/src/keystore/provider.js +12 -3
- package/src/varsig/assertion.js +11 -4
- package/src/varsig/utils.js +44 -11
- package/src/webauthn/provider.js +38 -3
- package/types/index.d.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,163 @@
|
|
|
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
|
+
|
|
114
|
+
## 0.5.0
|
|
115
|
+
|
|
116
|
+
### Breaking
|
|
117
|
+
|
|
118
|
+
- The varsig signing context moves into the signed payload, which **changes the
|
|
119
|
+
bytes a credential signs**. Entries and identities produced by 0.4.x and
|
|
120
|
+
earlier no longer verify, and peers running different major versions cannot
|
|
121
|
+
replicate with each other — the whole set has to move together.
|
|
122
|
+
|
|
123
|
+
The challenge used to be `SHA-256(label ‖ payload)`. That separated the three
|
|
124
|
+
signing contexts, but it meant the challenge was no longer the hash of the
|
|
125
|
+
payload, and the varsig WebAuthn draft has a verifier re-hash the payload and
|
|
126
|
+
compare it against the challenge (step 5). Nothing outside this package could
|
|
127
|
+
reproduce that value.
|
|
128
|
+
|
|
129
|
+
The label itself is not the problem: one credential signs an identity, a public
|
|
130
|
+
key and an oplog entry, and a signature over one must not be replayable as a
|
|
131
|
+
signature over another. That separation cannot live in the varsig header
|
|
132
|
+
either, because a WebAuthn signature only covers
|
|
133
|
+
`authenticatorData ‖ SHA-256(clientDataJSON)` — anything carried beside the
|
|
134
|
+
challenge is unauthenticated. So the context moves into the signed bytes:
|
|
135
|
+
`bindContext()` frames it length-prefixed ahead of the payload, and the
|
|
136
|
+
challenge becomes a plain SHA-256 multihash of those bytes. Both properties
|
|
137
|
+
now hold — the three contexts still yield different challenges, and a spec
|
|
138
|
+
verifier can reproduce the digest.
|
|
139
|
+
|
|
140
|
+
The length prefix is load-bearing. Without it, context `a` with payload `bc`
|
|
141
|
+
and context `ab` with payload `c` are the same bytes, so a chosen payload
|
|
142
|
+
could absorb the label and sidestep the separation.
|
|
143
|
+
|
|
144
|
+
This is a bet on a draft: ChainAgnostic/varsig#11 is still open with
|
|
145
|
+
`webauthn-varsig-header = TODO`. If the draft settles differently, the wire
|
|
146
|
+
format changes again.
|
|
147
|
+
|
|
148
|
+
- `iso-webauthn-varsig` moves to 0.3.0, which corrects the Ed25519 curve code in
|
|
149
|
+
the varsig header from `0xed01` to `0xed`. `0xed01` is how the multicodec
|
|
150
|
+
`ed25519-pub` is written in prose — but that is its _varint encoding_, and
|
|
151
|
+
using it as the code meant emitting 60673, which no other implementation reads
|
|
152
|
+
as Ed25519. Ed25519 varsigs written by earlier versions do not decode.
|
|
153
|
+
**P-256 bytes are unchanged by this second break**, so P-256 deployments are
|
|
154
|
+
affected only by the challenge change above.
|
|
155
|
+
|
|
156
|
+
### Migration
|
|
157
|
+
|
|
158
|
+
Nothing carries over automatically. Re-register credentials and re-create any
|
|
159
|
+
database whose entries were signed under 0.4.x, and upgrade every peer —
|
|
160
|
+
including relays that verify identities — in one step rather than gradually.
|
|
161
|
+
|
|
5
162
|
## 0.4.2
|
|
6
163
|
|
|
7
164
|
### Fixed
|
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:
|
|
@@ -305,6 +326,63 @@ Practical implication:
|
|
|
305
326
|
|
|
306
327
|
- If you create a passkey on one browser profile and later open the app in a fresh profile, the passkey may still exist in the platform passkey manager, but the app can only reconstruct the OrbitDB identity if it can recover metadata from `largeBlob` or some other persisted mapping.
|
|
307
328
|
|
|
329
|
+
## Upstream Packages and Temporary Forks
|
|
330
|
+
|
|
331
|
+
This package builds on the [`iso-repo`](https://github.com/hugomrdias/iso-repo)
|
|
332
|
+
toolkit by **[Hugo Dias](https://github.com/hugomrdias)** — `iso-base`,
|
|
333
|
+
`iso-did` and `iso-passkeys` do the WebAuthn parsing, DID encoding and base
|
|
334
|
+
conversion that this provider is built on top of. It is excellent work and this
|
|
335
|
+
package would be considerably larger without it. Thank you.
|
|
336
|
+
|
|
337
|
+
Four of the dependencies resolve to `@le-space` builds rather than the
|
|
338
|
+
published originals. Two different reasons, and they should not be confused:
|
|
339
|
+
|
|
340
|
+
### Temporary forks
|
|
341
|
+
|
|
342
|
+
Published only to unblock this package, and meant to disappear. Each carries a
|
|
343
|
+
small delta that belongs upstream.
|
|
344
|
+
|
|
345
|
+
| Dependency | Upstream | Why it is forked |
|
|
346
|
+
| -------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
347
|
+
| `iso-passkeys` | [iso-passkeys](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-passkeys) | Re-exports `parseAttestationObject` and `unwrapEC2Signature`, which already exist upstream but are not part of the public API |
|
|
348
|
+
| `iso-base` | [iso-base](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-base) | Kept in step with the forks above |
|
|
349
|
+
| `iso-did` | [iso-did](https://github.com/hugomrdias/iso-repo/tree/main/packages/iso-did) | Kept in step with the forks above |
|
|
350
|
+
|
|
351
|
+
The `iso-passkeys` delta is two lines and purely additive — nothing is changed
|
|
352
|
+
or removed, two existing internal functions are simply exported. It is proposed
|
|
353
|
+
upstream as
|
|
354
|
+
[hugomrdias/iso-repo#543](https://github.com/hugomrdias/iso-repo/pull/543); once
|
|
355
|
+
that lands, these three forks and their entries in `pnpm.overrides` can go. The
|
|
356
|
+
remaining entries in that block — `iso-web`, `conf>ajv`, `ajv>fast-uri` — are
|
|
357
|
+
unrelated and stay.
|
|
358
|
+
|
|
359
|
+
### Not a fork
|
|
360
|
+
|
|
361
|
+
`iso-webauthn-varsig` is a **new** package rather than a modified copy of an
|
|
362
|
+
existing one. It implements WebAuthn varsig signing for OrbitDB oplog entries
|
|
363
|
+
and has no upstream equivalent. It follows `iso-repo` conventions and lives in
|
|
364
|
+
the same lineage, which is why it is named the way it is.
|
|
365
|
+
|
|
366
|
+
It follows the non-recursive varsig layout, which shipped in 0.2.0.
|
|
367
|
+
|
|
368
|
+
One thing is still open, and it is on the spec side rather than here: the
|
|
369
|
+
WebAuthn varsig header. `webauthn-varsig-header` is `TODO` in
|
|
370
|
+
[ChainAgnostic/varsig#11](https://github.com/ChainAgnostic/varsig/pull/11), so
|
|
371
|
+
the `0x300001` marker this package writes is a private-use codepoint chosen
|
|
372
|
+
here rather than an allocated one. Expect the wire format to change once that
|
|
373
|
+
is settled.
|
|
374
|
+
|
|
375
|
+
### Not forked at all
|
|
376
|
+
|
|
377
|
+
`iso-web` appears in `pnpm.overrides` but resolves to the genuine upstream
|
|
378
|
+
package. The entry only pins a version.
|
|
379
|
+
|
|
380
|
+
### Licensing
|
|
381
|
+
|
|
382
|
+
All `iso-repo` packages are MIT, © Hugo Dias. The forks keep the original
|
|
383
|
+
`license` and `author` fields, so authorship travels with them; only the
|
|
384
|
+
package name changes.
|
|
385
|
+
|
|
308
386
|
## License
|
|
309
387
|
|
|
310
388
|
MIT. See `LICENSE`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-identity-provider-webauthn-did",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"iso-did": "npm:@le-space/iso-did@2.1.2",
|
|
91
91
|
"iso-passkeys": "npm:@le-space/iso-passkeys",
|
|
92
92
|
"iso-web": "^3.1.2",
|
|
93
|
-
"iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.
|
|
93
|
+
"iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.3.0",
|
|
94
94
|
"multiformats": "^14.0.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
@@ -143,7 +143,7 @@
|
|
|
143
143
|
"iso-base": "npm:@le-space/iso-base",
|
|
144
144
|
"iso-did": "npm:@le-space/iso-did@2.1.2",
|
|
145
145
|
"iso-web": "^3.1.2",
|
|
146
|
-
"iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.
|
|
146
|
+
"iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.3.0",
|
|
147
147
|
"conf>ajv": "^8.18.0",
|
|
148
148
|
"ajv>fast-uri": "^3.1.4"
|
|
149
149
|
},
|
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/varsig/assertion.js
CHANGED
|
@@ -12,7 +12,12 @@ import {
|
|
|
12
12
|
verifyP256Signature,
|
|
13
13
|
verifyWebAuthnAssertion,
|
|
14
14
|
} from 'iso-webauthn-varsig';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
bindContext,
|
|
17
|
+
buildChallengeBytes,
|
|
18
|
+
toArrayBuffer,
|
|
19
|
+
toBytes,
|
|
20
|
+
} from './utils.js';
|
|
16
21
|
import { buildCredentialRequestOptions } from '../webauthn/config.js';
|
|
17
22
|
import { KEY_TYPES, WEBAUTHN_CLIENT_DATA_TYPES } from '../constants.js';
|
|
18
23
|
import {
|
|
@@ -44,7 +49,10 @@ async function runWebAuthnAssertionForPayload(
|
|
|
44
49
|
) {
|
|
45
50
|
const rpId = window.location.hostname;
|
|
46
51
|
const origin = window.location.origin;
|
|
47
|
-
|
|
52
|
+
// The context is framed into the signed bytes, not into the challenge
|
|
53
|
+
// preimage, so the challenge stays a plain hash of what was signed.
|
|
54
|
+
const signedBytes = bindContext(domainLabel, payloadBytes);
|
|
55
|
+
const challengeBytes = await buildChallengeBytes(signedBytes);
|
|
48
56
|
|
|
49
57
|
if (isTestMode()) {
|
|
50
58
|
return {
|
|
@@ -208,8 +216,7 @@ async function verifyVarsigForPayload(
|
|
|
208
216
|
}
|
|
209
217
|
const clientData = parseClientDataJSON(decoded.clientDataJSON);
|
|
210
218
|
const expectedChallenge = await buildChallengeBytes(
|
|
211
|
-
domainLabel,
|
|
212
|
-
payloadBytes
|
|
219
|
+
bindContext(domainLabel, payloadBytes)
|
|
213
220
|
);
|
|
214
221
|
const expectedChallengeEncoded = bytesToBase64url(expectedChallenge);
|
|
215
222
|
|
package/src/varsig/utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Byte and challenge helpers for varsig signing.
|
|
3
3
|
*/
|
|
4
|
-
import { concat } from 'iso-webauthn-varsig';
|
|
4
|
+
import { concat, varintEncode } from 'iso-webauthn-varsig';
|
|
5
5
|
|
|
6
6
|
const encoder = new TextEncoder();
|
|
7
7
|
|
|
@@ -27,18 +27,51 @@ function toBytes(data) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Frame a payload with the context it is signed for.
|
|
31
|
+
*
|
|
32
|
+
* The same credential signs three different things — an identity, a public key
|
|
33
|
+
* and an oplog entry — and a signature over one must not be reusable as a
|
|
34
|
+
* signature over another. The separation has to live inside the bytes that get
|
|
35
|
+
* hashed: the WebAuthn signature covers `authenticatorData ‖ SHA-256(clientDataJSON)`
|
|
36
|
+
* and nothing else, so a context field carried alongside the varsig would not
|
|
37
|
+
* be bound to the signature at all.
|
|
38
|
+
*
|
|
39
|
+
* It used to be mixed into the challenge preimage instead
|
|
40
|
+
* (`SHA-256(label ‖ payload)`), which separated the domains but meant the
|
|
41
|
+
* challenge was no longer the hash of the payload — so verifiers following the
|
|
42
|
+
* varsig spec could never reproduce it.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} context - Domain label, e.g. 'orbitdb-entry:'.
|
|
32
45
|
* @param {Uint8Array} payloadBytes - Payload bytes.
|
|
33
|
-
* @returns {
|
|
46
|
+
* @returns {Uint8Array} The bytes that are signed over.
|
|
34
47
|
*/
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
48
|
+
function bindContext(context, payloadBytes) {
|
|
49
|
+
const contextBytes = encoder.encode(context);
|
|
50
|
+
// Length-prefixed so the frame is unambiguous: without it, a context of
|
|
51
|
+
// 'a' with payload 'bc' and a context of 'ab' with payload 'c' would
|
|
52
|
+
// produce identical bytes.
|
|
53
|
+
return concat([
|
|
54
|
+
varintEncode(contextBytes.length),
|
|
55
|
+
contextBytes,
|
|
56
|
+
payloadBytes,
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a WebAuthn challenge as a multihash of the payload.
|
|
62
|
+
*
|
|
63
|
+
* Per the varsig WebAuthn draft the challenge carries the payload hash as a
|
|
64
|
+
* multihash, so a verifier can re-hash the payload and compare. `0x12 0x20` is
|
|
65
|
+
* the SHA-256 prefix and its 32-byte length.
|
|
66
|
+
*
|
|
67
|
+
* @param {Uint8Array} payloadBytes - The bytes being signed, context included.
|
|
68
|
+
* @returns {Promise<Uint8Array>} Multihash of the payload.
|
|
69
|
+
*/
|
|
70
|
+
async function buildChallengeBytes(payloadBytes) {
|
|
71
|
+
const hash = new Uint8Array(
|
|
72
|
+
await crypto.subtle.digest('SHA-256', payloadBytes)
|
|
40
73
|
);
|
|
41
|
-
return new Uint8Array(hash);
|
|
74
|
+
return concat([new Uint8Array([0x12, 0x20]), hash]);
|
|
42
75
|
}
|
|
43
76
|
|
|
44
|
-
export { encoder, toArrayBuffer, toBytes, buildChallengeBytes };
|
|
77
|
+
export { encoder, toArrayBuffer, toBytes, bindContext, buildChallengeBytes };
|
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;
|