@le-space/orbitdb-identity-provider-webauthn-did 0.5.0 → 0.5.2
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 +155 -0
- package/README.md +21 -0
- package/package.json +2 -2
- package/src/index.js +2 -0
- package/src/keystore/encryption.js +159 -22
- package/src/keystore/provider.js +39 -28
- package/src/varsig/assertion.js +2 -38
- package/src/varsig/credential.js +0 -20
- package/src/varsig/identity.js +16 -1
- package/src/webauthn/proof-verification.js +235 -0
- package/src/webauthn/provider.js +59 -72
- package/types/index.d.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,161 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.2
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
|
|
9
|
+
- **Identity verification checked shapes, not signatures**
|
|
10
|
+
([GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg)).
|
|
11
|
+
Four checks that decide whether an identity may speak for a DID verified
|
|
12
|
+
nothing: the static `verifyIdentity` OrbitDB calls accepted any `did:key:` of
|
|
13
|
+
type `webauthn`; `WebAuthnDIDProvider.verify()` accepted any proof with the
|
|
14
|
+
right fields; varsig verification returned `true` whenever
|
|
15
|
+
`window.__PLAYWRIGHT__` was set; and `verifyVarsigIdentity` never compared the
|
|
16
|
+
id with the key signing for it. Anyone who knew a DID could write as it
|
|
17
|
+
wherever write access was granted to that DID.
|
|
18
|
+
|
|
19
|
+
`src/webauthn/proof-verification.js` now decides it. A credential DID (P-256)
|
|
20
|
+
is bound through the WebAuthn assertion in `signatures.publicKey`, verified
|
|
21
|
+
against the key the DID itself encodes. A keystore DID (Ed25519, secp256k1)
|
|
22
|
+
must be exactly the key that signs. A varsig identity must carry the DID of
|
|
23
|
+
its own key. The test-mode branches are gone from `src/`.
|
|
24
|
+
|
|
25
|
+
**Check before upgrading** — three things that were accepted and now are not:
|
|
26
|
+
- Entries written under `useKeystoreDID` by 0.5.1 or earlier. Those identities
|
|
27
|
+
signed with a key unrelated to their DID (see _Fixed_), which makes their
|
|
28
|
+
entries indistinguishable from forgeries, so upgraded peers refuse them.
|
|
29
|
+
Identities with the default credential DID are unaffected.
|
|
30
|
+
- Legacy 64-hex identity ids. They cannot be bound to any key.
|
|
31
|
+
- `WebAuthnDIDProvider.verify(signature)` with one argument. It now needs the
|
|
32
|
+
signed data and the public key: `verify(signature, data, publicKey)`.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `useKeystoreDID` with `keystoreKeyType: 'Ed25519'` signed entries with a
|
|
37
|
+
secp256k1 key. The provider stored its key under the credential's P-256 DID;
|
|
38
|
+
OrbitDB looked it up under the keystore DID, found nothing and generated a
|
|
39
|
+
fresh secp256k1 key, so only the DID was Ed25519. The key now goes under the
|
|
40
|
+
DID OrbitDB looks it up by, encrypted and unencrypted.
|
|
41
|
+
- The Ed25519 demo kept `prfInput` across a reload as a plain object, which the
|
|
42
|
+
browser refused as a PRF input; the worker's seed then fell back to the
|
|
43
|
+
credential ID and its archive could no longer be opened.
|
|
44
|
+
|
|
45
|
+
### Tests
|
|
46
|
+
|
|
47
|
+
- `tests/identity-forgery.test.js`: every forgery above is refused, and beside
|
|
48
|
+
each the genuine identity still passes. The browser suites use a Chromium
|
|
49
|
+
virtual authenticator instead of mocks that signed with 64 zero bytes.
|
|
50
|
+
|
|
51
|
+
## 0.5.1
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- The WebAuthn user handle is 64 random bytes instead of the UTF-8 of whatever
|
|
56
|
+
the caller passes as `userId`. An authenticator keeps one discoverable
|
|
57
|
+
credential per `(rp.id, user.id)` and **replaces** the previous one when both
|
|
58
|
+
match — silently, with no prompt and nothing to undo. Deriving the handle from
|
|
59
|
+
a typed name therefore meant two people registering as "anna" on a shared
|
|
60
|
+
device destroyed each other's passkey, and with it the DID and every entry
|
|
61
|
+
signed under it. The same happened to one person re-entering their usual name
|
|
62
|
+
after clearing storage. WebAuthn L2 §5.4.3 also forbids putting personally
|
|
63
|
+
identifying information in the handle, which a name or e-mail plainly is, and
|
|
64
|
+
the handle is stored in the authenticator indefinitely.
|
|
65
|
+
|
|
66
|
+
`userId` keeps its rightful place as `user.name`, the label the credential
|
|
67
|
+
picker shows. It is a label only: it identifies nothing, two credentials may
|
|
68
|
+
carry the same one, and nothing in this package looks a credential up by it.
|
|
69
|
+
Closes #45.
|
|
70
|
+
|
|
71
|
+
Nothing needs migrating. Recovery goes through discoverable credentials
|
|
72
|
+
(`readLargeBlobMetadata` calls `get()` with no `allowCredentials`), or through
|
|
73
|
+
an explicit credential ID where those are switched off — no path resolves a
|
|
74
|
+
credential by handle, so credentials registered under the old scheme keep
|
|
75
|
+
working untouched. The derived `did:key` comes from the credential's public
|
|
76
|
+
key and does not change.
|
|
77
|
+
|
|
78
|
+
Consumers should expect a behavioural difference: re-registering under a name
|
|
79
|
+
that was used before now **adds** a passkey instead of replacing one. That is
|
|
80
|
+
the point — a replaced passkey is data loss, a second entry is a choice — but
|
|
81
|
+
it means the picker can show several, so `user.name` and `displayName` should
|
|
82
|
+
be distinguishing enough to pick from. The new random handle is returned as
|
|
83
|
+
`credential.userHandle` (base64url); the authenticator keeps its own copy, so
|
|
84
|
+
storing it is optional.
|
|
85
|
+
|
|
86
|
+
- Actually write the secret key into `largeBlob`. The keystore record carried
|
|
87
|
+
`secretKey: sk, // Will be moved to largeBlob` — and nothing ever moved it.
|
|
88
|
+
That was the only occurrence of the field in `src/`, and
|
|
89
|
+
`storeEncryptedKeystore()` serialises a fixed whitelist that does not include
|
|
90
|
+
it, so the key was dropped at persist time. Choosing largeBlob therefore
|
|
91
|
+
produced a keystore that worked for the session that created it and could
|
|
92
|
+
never be unlocked again: on the next load `retrieveSKFromLargeBlob()` read a
|
|
93
|
+
blob nobody had written and failed with "No largeBlob data found in
|
|
94
|
+
credential".
|
|
95
|
+
|
|
96
|
+
The key never reached disk in the clear — the whitelist saw to that — but the
|
|
97
|
+
feature was inert. `writeSKToLargeBlob()` now performs the assertion that
|
|
98
|
+
stores it (a blob can only be written during an assertion, never at
|
|
99
|
+
registration, so this costs one extra prompt) and **throws unless the
|
|
100
|
+
authenticator reports `written: true`**. Failing there beats persisting a
|
|
101
|
+
record that cannot be opened.
|
|
102
|
+
|
|
103
|
+
- Carry the authenticator's own answer on the credential. `createCredential()`
|
|
104
|
+
now records `extensionSupport` — what the authenticator agreed to during the
|
|
105
|
+
ceremony — because the raw `PublicKeyCredential` does not survive past that
|
|
106
|
+
function, so the answer is read there or lost. Client support is not
|
|
107
|
+
authenticator support: measured on a macOS platform authenticator in Brave,
|
|
108
|
+
the client advertises `hmacCreateSecret` while the authenticator refuses it
|
|
109
|
+
and PRF works. Reading only the client is what left the demo offering
|
|
110
|
+
hmac-secret and then failing at the ceremony, which is the failure originally
|
|
111
|
+
reported in #9.
|
|
112
|
+
|
|
113
|
+
The encrypted-keystore demo consumes it: methods the authenticator refused
|
|
114
|
+
read "Browser yes, this passkey no" rather than "Supported", become
|
|
115
|
+
unselectable, and a selection it cannot honour falls back automatically. It
|
|
116
|
+
also no longer logs `encrypted: Yes (largeBlob)` off the back of the
|
|
117
|
+
_requested_ options — that line reported intent as though it were outcome, so
|
|
118
|
+
a failed write looked identical to a successful one. It now names the request
|
|
119
|
+
and the authenticator's capability separately, so a mismatch is visible.
|
|
120
|
+
|
|
121
|
+
- Detect WebAuthn extensions by asking the browser instead of inspecting a
|
|
122
|
+
prototype. `checkExtensionSupport()` tested
|
|
123
|
+
`'largeBlob' in PublicKeyCredential.prototype` — but extensions are never
|
|
124
|
+
properties of that interface, they arrive through
|
|
125
|
+
`getClientExtensionResults()`. The test therefore returned `false` in every
|
|
126
|
+
browser ever shipped, including those with complete support, and `hmacSecret`
|
|
127
|
+
was hard-coded `false` beside it. Both together meant the encrypted-keystore
|
|
128
|
+
demo disabled its own headline feature everywhere and could not be switched
|
|
129
|
+
on at all.
|
|
130
|
+
|
|
131
|
+
Measured on Chrome 148: the prototype probe answered `false` for `largeBlob`,
|
|
132
|
+
`prf` and `hmacCreateSecret` while `getClientCapabilities()` reported all
|
|
133
|
+
three `true`. Every extension name behaves the same way, so no browser could
|
|
134
|
+
ever have passed. Closes #9.
|
|
135
|
+
|
|
136
|
+
`checkExtensionSupport()` now reads `getClientCapabilities()` and reports
|
|
137
|
+
`prf` alongside the other two. It also returns `known`, distinguishing "the
|
|
138
|
+
browser says no" from "the browser cannot say" — callers must not treat the
|
|
139
|
+
second as a refusal, or they disable a feature that may well work.
|
|
140
|
+
|
|
141
|
+
### Added
|
|
142
|
+
|
|
143
|
+
- `extensionSupportFromCredential(credential)` reads what the authenticator
|
|
144
|
+
actually agreed to, from the registration response. This is the authoritative
|
|
145
|
+
answer and the one worth persisting: a browser may support `largeBlob` while
|
|
146
|
+
the security key in front of it does not, and only the ceremony settles that.
|
|
147
|
+
`addLargeBlobToCredentialOptions()` takes an optional support level for the
|
|
148
|
+
same reason — it stays `'required'` by default, because under `'preferred'`
|
|
149
|
+
an unsupported authenticator yields a credential whose secret was silently
|
|
150
|
+
never written.
|
|
151
|
+
|
|
152
|
+
### Changed
|
|
153
|
+
|
|
154
|
+
- The encrypted-keystore demo offers PRF as a keystore encryption method and
|
|
155
|
+
prefers it, matching the library's own default since 0.4.0. It had hard-coded
|
|
156
|
+
`largeBlob`, so it overrode that default and never exercised PRF at all.
|
|
157
|
+
Methods the browser cannot vouch for are now labelled "Unknown" rather than
|
|
158
|
+
"Not supported", and stay selectable.
|
|
159
|
+
|
|
5
160
|
## 0.5.0
|
|
6
161
|
|
|
7
162
|
### 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.2",
|
|
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",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"iso-web": "^3.1.2",
|
|
146
146
|
"iso-webauthn-varsig": "npm:@le-space/iso-webauthn-varsig@^0.3.0",
|
|
147
147
|
"conf>ajv": "^8.18.0",
|
|
148
|
-
"ajv>fast-uri": "^3.1.
|
|
148
|
+
"ajv>fast-uri": "^3.1.6"
|
|
149
149
|
},
|
|
150
150
|
"onlyBuiltDependencies": [
|
|
151
151
|
"@ipshipyard/node-datachannel",
|
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
|
@@ -9,6 +9,7 @@ import { base58btc } from 'multiformats/bases/base58';
|
|
|
9
9
|
import * as KeystoreEncryption from './encryption.js';
|
|
10
10
|
import { ensureDerivedSigningKey } from './derived-signing-key.js';
|
|
11
11
|
import { WebAuthnDIDProvider } from '../webauthn/provider.js';
|
|
12
|
+
import { verifyWebAuthnIdentityBinding } from '../webauthn/proof-verification.js';
|
|
12
13
|
import {
|
|
13
14
|
identityProofKey,
|
|
14
15
|
loadIdentityProof,
|
|
@@ -171,12 +172,24 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
171
172
|
const publicKeyBytes = normalizeByteArray(encryptedData.publicKey);
|
|
172
173
|
const keyType = encryptedData.keyType || this.keystoreKeyType;
|
|
173
174
|
|
|
174
|
-
|
|
175
|
+
const did = this.createDIDFromKeystorePublicKey(
|
|
175
176
|
publicKeyBytes,
|
|
176
177
|
keyType,
|
|
177
178
|
varint,
|
|
178
179
|
base58btc
|
|
179
180
|
);
|
|
181
|
+
// OrbitDB signs with whatever key it finds under the id, and creates a
|
|
182
|
+
// secp256k1 one when there is none — so without this the DID said
|
|
183
|
+
// Ed25519 and the entries were signed by an unrelated key.
|
|
184
|
+
if (
|
|
185
|
+
this.unlockedKeypair?.privateKey &&
|
|
186
|
+
!(await this.keystore.getKey(did))
|
|
187
|
+
) {
|
|
188
|
+
await this.keystore.addKey(did, {
|
|
189
|
+
privateKey: this.unlockedKeypair.privateKey,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return did;
|
|
180
193
|
}
|
|
181
194
|
|
|
182
195
|
// Get the keystore's identity ID (this will be used to retrieve the key)
|
|
@@ -293,12 +306,18 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
293
306
|
keystoreKey.type
|
|
294
307
|
);
|
|
295
308
|
|
|
296
|
-
|
|
309
|
+
const did = this.createDIDFromKeystorePublicKey(
|
|
297
310
|
publicKeyBytes,
|
|
298
311
|
keystoreKey.type,
|
|
299
312
|
varint,
|
|
300
313
|
base58btc
|
|
301
314
|
);
|
|
315
|
+
// Stored above under the credential's P-256 DID; OrbitDB looks it up under
|
|
316
|
+
// the DID returned here, found nothing, and generated a secp256k1 key.
|
|
317
|
+
if (!(await this.keystore.getKey(did))) {
|
|
318
|
+
await this.keystore.addKey(did, { privateKey: keystoreKey.raw });
|
|
319
|
+
}
|
|
320
|
+
return did;
|
|
302
321
|
} catch (error) {
|
|
303
322
|
identityLog.error(
|
|
304
323
|
'Failed to create Ed25519 DID from keystore: %s',
|
|
@@ -408,15 +427,24 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
408
427
|
} else if (
|
|
409
428
|
this.keystoreEncryptionMethod === KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB
|
|
410
429
|
) {
|
|
411
|
-
//
|
|
412
|
-
//
|
|
430
|
+
// Write the key into the authenticator before recording anything.
|
|
431
|
+
// This used to be `secretKey: sk, // Will be moved to largeBlob` — and
|
|
432
|
+
// nothing ever moved it: the field was dropped at serialization, so
|
|
433
|
+
// the key survived only in memory and the keystore could never be
|
|
434
|
+
// unlocked again. Throws if the authenticator refuses, which is better
|
|
435
|
+
// than persisting a record that cannot be opened.
|
|
436
|
+
await KeystoreEncryption.writeSKToLargeBlob(
|
|
437
|
+
this.credential.rawCredentialId,
|
|
438
|
+
sk,
|
|
439
|
+
window.location.hostname
|
|
440
|
+
);
|
|
441
|
+
|
|
413
442
|
encryptedData = {
|
|
414
443
|
ciphertext,
|
|
415
444
|
iv,
|
|
416
445
|
credentialId: this.credential.credentialId,
|
|
417
446
|
publicKey: publicKeyBytes,
|
|
418
447
|
keyType,
|
|
419
|
-
secretKey: sk, // Will be moved to largeBlob
|
|
420
448
|
encryptionMethod: KEYSTORE_ENCRYPTION_METHODS.LARGE_BLOB,
|
|
421
449
|
};
|
|
422
450
|
} else if (
|
|
@@ -725,27 +753,10 @@ OrbitDBWebAuthnIdentityProviderFunction.type = IDENTITY_TYPES.WEBAUTHN;
|
|
|
725
753
|
OrbitDBWebAuthnIdentityProviderFunction.verifyIdentity = async function (
|
|
726
754
|
identity
|
|
727
755
|
) {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
// so we can trust it if it has the right structure
|
|
735
|
-
// Accept both DID format (did:key:...) and hash format (hex string) for backward compatibility
|
|
736
|
-
const isValidDID = identity.id && identity.id.startsWith(DID_KEY_PREFIX);
|
|
737
|
-
const isValidHash = identity.id && /^[a-f0-9]{64}$/.test(identity.id); // 64-char hex string (legacy)
|
|
738
|
-
|
|
739
|
-
if (
|
|
740
|
-
identity.type === IDENTITY_TYPES.WEBAUTHN &&
|
|
741
|
-
(isValidDID || isValidHash)
|
|
742
|
-
) {
|
|
743
|
-
return true;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
return false;
|
|
747
|
-
} catch (error) {
|
|
748
|
-
console.error('WebAuthn static identity verification failed:', error);
|
|
749
|
-
return false;
|
|
750
|
-
}
|
|
756
|
+
// The check OrbitDB relies on to decide whether this identity may speak for
|
|
757
|
+
// its DID. It used to accept any `did:key:` of type `webauthn`, so a peer
|
|
758
|
+
// holding only its own key could write as anyone in a write list
|
|
759
|
+
// (GHSA-326j-4cc3-4rrg). See ../webauthn/proof-verification.js.
|
|
760
|
+
if (identity?.type !== IDENTITY_TYPES.WEBAUTHN) return false;
|
|
761
|
+
return verifyWebAuthnIdentityBinding(identity);
|
|
751
762
|
};
|
package/src/varsig/assertion.js
CHANGED
|
@@ -19,16 +19,13 @@ import {
|
|
|
19
19
|
toBytes,
|
|
20
20
|
} from './utils.js';
|
|
21
21
|
import { buildCredentialRequestOptions } from '../webauthn/config.js';
|
|
22
|
-
import { KEY_TYPES
|
|
22
|
+
import { KEY_TYPES } from '../constants.js';
|
|
23
23
|
import {
|
|
24
24
|
VarsigVerificationError,
|
|
25
25
|
WebAuthnAuthenticationError,
|
|
26
26
|
} from '../errors.js';
|
|
27
27
|
import { logWebAuthnResponse } from '../webauthn/debug-log.js';
|
|
28
28
|
|
|
29
|
-
const isTestMode = () =>
|
|
30
|
-
typeof window !== 'undefined' && window.__PLAYWRIGHT__ === true;
|
|
31
|
-
|
|
32
29
|
/**
|
|
33
30
|
* Run a WebAuthn assertion for a payload.
|
|
34
31
|
* @param {Object} credential - WebAuthn credential info.
|
|
@@ -54,27 +51,6 @@ async function runWebAuthnAssertionForPayload(
|
|
|
54
51
|
const signedBytes = bindContext(domainLabel, payloadBytes);
|
|
55
52
|
const challengeBytes = await buildChallengeBytes(signedBytes);
|
|
56
53
|
|
|
57
|
-
if (isTestMode()) {
|
|
58
|
-
return {
|
|
59
|
-
rpId,
|
|
60
|
-
origin,
|
|
61
|
-
challengeBytes,
|
|
62
|
-
algorithm: credential.algorithm,
|
|
63
|
-
publicKey: credential.publicKey,
|
|
64
|
-
assertion: {
|
|
65
|
-
authenticatorData: new Uint8Array(37),
|
|
66
|
-
clientDataJSON: new TextEncoder().encode(
|
|
67
|
-
JSON.stringify({
|
|
68
|
-
type: WEBAUTHN_CLIENT_DATA_TYPES.GET,
|
|
69
|
-
challenge: 'test',
|
|
70
|
-
origin,
|
|
71
|
-
})
|
|
72
|
-
),
|
|
73
|
-
signature: new Uint8Array(64),
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
54
|
const assertion = await navigator.credentials.get(
|
|
79
55
|
buildCredentialRequestOptions({
|
|
80
56
|
rpId,
|
|
@@ -114,15 +90,6 @@ async function runWebAuthnAssertionForPayload(
|
|
|
114
90
|
* @returns {Promise<{varsig: Uint8Array, clientData: Object, verification: Object, signatureValid: boolean}>}
|
|
115
91
|
*/
|
|
116
92
|
async function buildVarsigOutput(assertionData) {
|
|
117
|
-
if (isTestMode()) {
|
|
118
|
-
return {
|
|
119
|
-
varsig: new Uint8Array([1, 2, 3]),
|
|
120
|
-
clientData: {},
|
|
121
|
-
verification: { valid: true },
|
|
122
|
-
signatureValid: true,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
93
|
const { assertion, algorithm, origin, rpId, challengeBytes, publicKey } =
|
|
127
94
|
assertionData;
|
|
128
95
|
|
|
@@ -200,10 +167,6 @@ async function verifyVarsigForPayload(
|
|
|
200
167
|
payloadBytes,
|
|
201
168
|
domainLabel
|
|
202
169
|
) {
|
|
203
|
-
if (isTestMode()) {
|
|
204
|
-
return true;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
170
|
let decoded;
|
|
208
171
|
try {
|
|
209
172
|
decoded = decodeWebAuthnVarsigV1(signature);
|
|
@@ -252,6 +215,7 @@ async function verifyVarsigForPayload(
|
|
|
252
215
|
}
|
|
253
216
|
|
|
254
217
|
export {
|
|
218
|
+
algorithmFromPublicKey,
|
|
255
219
|
buildVarsigOutput,
|
|
256
220
|
runWebAuthnAssertionForPayload,
|
|
257
221
|
verifyVarsigForPayload,
|
package/src/varsig/credential.js
CHANGED
|
@@ -7,9 +7,6 @@ import { toArrayBuffer } from './utils.js';
|
|
|
7
7
|
import { buildAuthenticatorSelection } from '../webauthn/config.js';
|
|
8
8
|
import { logWebAuthnResponse } from '../webauthn/debug-log.js';
|
|
9
9
|
|
|
10
|
-
const isTestMode = () =>
|
|
11
|
-
typeof window !== 'undefined' && window.__PLAYWRIGHT__ === true;
|
|
12
|
-
|
|
13
10
|
/**
|
|
14
11
|
* Extract public key info from a WebAuthn attestation object.
|
|
15
12
|
* @param {Uint8Array} attestationObject - Raw attestation object bytes.
|
|
@@ -81,23 +78,6 @@ async function createWebAuthnVarsigCredential(options = {}) {
|
|
|
81
78
|
...options,
|
|
82
79
|
};
|
|
83
80
|
|
|
84
|
-
if (isTestMode()) {
|
|
85
|
-
const credentialId = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
86
|
-
const publicKey = new Uint8Array(32);
|
|
87
|
-
for (let i = 0; i < publicKey.length; i++) {
|
|
88
|
-
publicKey[i] = i + 1;
|
|
89
|
-
}
|
|
90
|
-
const algorithm = 'Ed25519';
|
|
91
|
-
const did = DIDKey.fromPublicKey(algorithm, publicKey).did;
|
|
92
|
-
return {
|
|
93
|
-
credentialId,
|
|
94
|
-
publicKey,
|
|
95
|
-
did,
|
|
96
|
-
algorithm,
|
|
97
|
-
cose: { kty: 1, alg: -8, crv: 6 },
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
81
|
const publicKey = {
|
|
102
82
|
rp: { name: 'OrbitDB Varsig Identity', id: domain },
|
|
103
83
|
user: {
|
package/src/varsig/identity.js
CHANGED
|
@@ -10,7 +10,7 @@ import { DIDKey } from 'iso-did';
|
|
|
10
10
|
import { concat } from 'iso-webauthn-varsig';
|
|
11
11
|
import { DEFAULT_DOMAIN_LABELS } from './domain.js';
|
|
12
12
|
import { encoder, toBytes } from './utils.js';
|
|
13
|
-
import { verifyVarsigForPayload } from './assertion.js';
|
|
13
|
+
import { verifyVarsigForPayload, algorithmFromPublicKey } from './assertion.js';
|
|
14
14
|
import { WebAuthnVarsigProvider } from './provider.js';
|
|
15
15
|
|
|
16
16
|
const IDENTITY_CODEC = dagCbor;
|
|
@@ -330,6 +330,21 @@ export async function decodeVarsigIdentityFromBytes(bytes, domainLabels = {}) {
|
|
|
330
330
|
*/
|
|
331
331
|
export async function verifyVarsigIdentity(identity, domainLabels = {}) {
|
|
332
332
|
if (!identity || !identity.publicKey || !identity.signatures) return false;
|
|
333
|
+
|
|
334
|
+
// The id has to be the DID of the key that signs for it. Both signatures
|
|
335
|
+
// below are checked against `identity.publicKey`, so without this anyone
|
|
336
|
+
// with a passkey on the same origin could sign a stranger's DID with their
|
|
337
|
+
// own key and pass.
|
|
338
|
+
let expectedDid;
|
|
339
|
+
try {
|
|
340
|
+
expectedDid = DIDKey.fromPublicKey(
|
|
341
|
+
algorithmFromPublicKey(identity.publicKey),
|
|
342
|
+
identity.publicKey
|
|
343
|
+
).did;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
if (identity.id !== expectedDid) return false;
|
|
333
348
|
const labels = { ...DEFAULT_DOMAIN_LABELS, ...domainLabels };
|
|
334
349
|
|
|
335
350
|
const idBytes = encoder.encode(identity.id);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ProofVerification
|
|
3
|
+
* @description
|
|
4
|
+
* Checks that an identity's claim to a DID is backed by the key that DID
|
|
5
|
+
* encodes.
|
|
6
|
+
*
|
|
7
|
+
* OrbitDB verifies `signatures.id` against the identity's own `publicKey`, which
|
|
8
|
+
* any key satisfies for itself, and then asks the provider whether the rest
|
|
9
|
+
* holds. So this is the only place where "this identity may speak for that DID"
|
|
10
|
+
* is decided — and until GHSA-326j-4cc3-4rrg it decided nothing: the static
|
|
11
|
+
* `verifyIdentity` looked at the type and the `did:key:` prefix, and
|
|
12
|
+
* `WebAuthnDIDProvider.verify()` looked at the proof's fields. A peer holding
|
|
13
|
+
* only its own key could claim any DID in any write list.
|
|
14
|
+
*
|
|
15
|
+
* Two kinds of DID reach here, and each is bound differently:
|
|
16
|
+
*
|
|
17
|
+
* - **P-256, the credential's own key** (multicodec 0x1200). The passkey signed
|
|
18
|
+
* `publicKey + signatures.id` during `createIdentity`; that WebAuthn assertion
|
|
19
|
+
* is `signatures.publicKey`. It is verified against the key the DID encodes,
|
|
20
|
+
* so no lookup is needed — the DID *is* the verification key.
|
|
21
|
+
* - **Ed25519 or secp256k1, a keystore key** (`useKeystoreDID`). The DID is the
|
|
22
|
+
* signing key itself, so `publicKey` must be exactly the key it encodes;
|
|
23
|
+
* OrbitDB has already checked that key signed the id.
|
|
24
|
+
*
|
|
25
|
+
* Anything else — including the legacy 64-hex ids — cannot be bound to a key,
|
|
26
|
+
* and is refused rather than waved through.
|
|
27
|
+
*/
|
|
28
|
+
import { varint } from 'multiformats';
|
|
29
|
+
import { base58btc } from 'multiformats/bases/base58';
|
|
30
|
+
import { unwrapEC2Signature } from 'iso-passkeys';
|
|
31
|
+
|
|
32
|
+
import { DID_KEY_PREFIX, WEBAUTHN_CLIENT_DATA_TYPES } from '../constants.js';
|
|
33
|
+
|
|
34
|
+
export const MULTICODEC = Object.freeze({
|
|
35
|
+
P256_PUB: 0x1200,
|
|
36
|
+
ED25519_PUB: 0xed,
|
|
37
|
+
SECP256K1_PUB: 0xe7,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const FLAG_USER_PRESENT = 0x01;
|
|
41
|
+
// rpIdHash (32) + flags (1) + signCount (4)
|
|
42
|
+
const AUTH_DATA_MIN_LENGTH = 37;
|
|
43
|
+
const P1363_SIGNATURE_LENGTH = 64;
|
|
44
|
+
|
|
45
|
+
function base64urlToBytes(value) {
|
|
46
|
+
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
47
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
48
|
+
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bytesToBase64url(bytes) {
|
|
52
|
+
let binary = '';
|
|
53
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
54
|
+
return btoa(binary)
|
|
55
|
+
.replace(/\+/g, '-')
|
|
56
|
+
.replace(/\//g, '_')
|
|
57
|
+
.replace(/=+$/, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function concat(a, b) {
|
|
61
|
+
const out = new Uint8Array(a.length + b.length);
|
|
62
|
+
out.set(a, 0);
|
|
63
|
+
out.set(b, a.length);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function sha256(bytes) {
|
|
68
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {Uint8Array} bytes
|
|
73
|
+
* @returns {string} lowercase hex
|
|
74
|
+
*/
|
|
75
|
+
export function bytesToHex(bytes) {
|
|
76
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
|
77
|
+
''
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Split a `did:key` into its multicodec and raw public key.
|
|
83
|
+
* @param {string} did
|
|
84
|
+
* @returns {{ codec: number, keyBytes: Uint8Array }}
|
|
85
|
+
*/
|
|
86
|
+
export function decodeDidKey(did) {
|
|
87
|
+
if (typeof did !== 'string' || !did.startsWith(`${DID_KEY_PREFIX}z`)) {
|
|
88
|
+
throw new Error('Not a base58btc did:key');
|
|
89
|
+
}
|
|
90
|
+
const bytes = base58btc.decode(did.slice(DID_KEY_PREFIX.length));
|
|
91
|
+
const [codec, prefixLength] = varint.decode(bytes);
|
|
92
|
+
return { codec, keyBytes: bytes.subarray(prefixLength) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A P-256 public key as raw SEC1 bytes, from either raw bytes or `{ x, y }`.
|
|
97
|
+
* @param {Uint8Array|{x: ArrayLike<number>, y: ArrayLike<number>}|undefined} publicKey
|
|
98
|
+
* @returns {Uint8Array|null}
|
|
99
|
+
*/
|
|
100
|
+
export function p256PublicKeyBytes(publicKey) {
|
|
101
|
+
if (publicKey instanceof Uint8Array) {
|
|
102
|
+
if (publicKey.length === 65 && publicKey[0] === 0x04) return publicKey;
|
|
103
|
+
if (publicKey.length === 33) return publicKey;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (publicKey?.x && publicKey?.y) {
|
|
107
|
+
const x = Uint8Array.from(publicKey.x);
|
|
108
|
+
const y = Uint8Array.from(publicKey.y);
|
|
109
|
+
if (x.length !== 32 || y.length !== 32) return null;
|
|
110
|
+
const out = new Uint8Array(65);
|
|
111
|
+
out[0] = 0x04;
|
|
112
|
+
out.set(x, 1);
|
|
113
|
+
out.set(y, 33);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify a proof produced by `WebAuthnDIDProvider.sign(data)`.
|
|
121
|
+
*
|
|
122
|
+
* Checks, in order: the client data type; that the challenge is the one
|
|
123
|
+
* `sign()` derives — SHA-256(rawCredentialId ‖ data) — so the assertion is
|
|
124
|
+
* about this data and no other; that the user was present; and the ECDSA
|
|
125
|
+
* P-256 signature over `authenticatorData ‖ SHA-256(clientDataJSON)` against
|
|
126
|
+
* the given key. Never throws: a malformed proof is simply not valid.
|
|
127
|
+
*
|
|
128
|
+
* Origin and rpId are not checked. They defend an authentication ceremony
|
|
129
|
+
* against relaying; here the question is only whether the holder of this key
|
|
130
|
+
* signed this data, which the signature answers wherever it was made.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} encodedProof base64url JSON, as returned by `sign()`
|
|
133
|
+
* @param {string|Uint8Array} data what was signed
|
|
134
|
+
* @param {Uint8Array} publicKeyBytes P-256 public key, SEC1
|
|
135
|
+
* @returns {Promise<boolean>}
|
|
136
|
+
*/
|
|
137
|
+
export async function verifyWebAuthnProof(encodedProof, data, publicKeyBytes) {
|
|
138
|
+
try {
|
|
139
|
+
if (typeof encodedProof !== 'string' || !publicKeyBytes) return false;
|
|
140
|
+
const proof = JSON.parse(
|
|
141
|
+
new TextDecoder().decode(base64urlToBytes(encodedProof))
|
|
142
|
+
);
|
|
143
|
+
const { credentialId, authenticatorData, clientDataJSON, signature } =
|
|
144
|
+
proof ?? {};
|
|
145
|
+
if (
|
|
146
|
+
typeof credentialId !== 'string' ||
|
|
147
|
+
typeof authenticatorData !== 'string' ||
|
|
148
|
+
typeof clientDataJSON !== 'string' ||
|
|
149
|
+
typeof signature !== 'string'
|
|
150
|
+
) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const clientData = JSON.parse(clientDataJSON);
|
|
155
|
+
if (clientData?.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) return false;
|
|
156
|
+
|
|
157
|
+
const dataBytes =
|
|
158
|
+
typeof data === 'string'
|
|
159
|
+
? new TextEncoder().encode(data)
|
|
160
|
+
: new Uint8Array(data);
|
|
161
|
+
const expectedChallenge = bytesToBase64url(
|
|
162
|
+
await sha256(concat(base64urlToBytes(credentialId), dataBytes))
|
|
163
|
+
);
|
|
164
|
+
if (clientData.challenge !== expectedChallenge) return false;
|
|
165
|
+
|
|
166
|
+
const authData = base64urlToBytes(authenticatorData);
|
|
167
|
+
if (authData.length < AUTH_DATA_MIN_LENGTH) return false;
|
|
168
|
+
if ((authData[32] & FLAG_USER_PRESENT) === 0) return false;
|
|
169
|
+
|
|
170
|
+
const signedBytes = concat(
|
|
171
|
+
authData,
|
|
172
|
+
await sha256(new TextEncoder().encode(clientDataJSON))
|
|
173
|
+
);
|
|
174
|
+
const signatureBytes = base64urlToBytes(signature);
|
|
175
|
+
const p1363 =
|
|
176
|
+
signatureBytes.length === P1363_SIGNATURE_LENGTH
|
|
177
|
+
? signatureBytes
|
|
178
|
+
: Uint8Array.from(unwrapEC2Signature(signatureBytes));
|
|
179
|
+
|
|
180
|
+
const key = await crypto.subtle.importKey(
|
|
181
|
+
'raw',
|
|
182
|
+
publicKeyBytes,
|
|
183
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
184
|
+
false,
|
|
185
|
+
['verify']
|
|
186
|
+
);
|
|
187
|
+
return await crypto.subtle.verify(
|
|
188
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
189
|
+
key,
|
|
190
|
+
p1363,
|
|
191
|
+
signedBytes
|
|
192
|
+
);
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether an OrbitDB identity of type `webauthn` may speak for its `id`.
|
|
200
|
+
* @param {{id: string, publicKey: string, signatures: {id: string, publicKey: string}}} identity
|
|
201
|
+
* @returns {Promise<boolean>}
|
|
202
|
+
*/
|
|
203
|
+
export async function verifyWebAuthnIdentityBinding(identity) {
|
|
204
|
+
if (
|
|
205
|
+
!identity ||
|
|
206
|
+
typeof identity.publicKey !== 'string' ||
|
|
207
|
+
!identity.signatures
|
|
208
|
+
) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let decoded;
|
|
213
|
+
try {
|
|
214
|
+
decoded = decodeDidKey(identity.id);
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (decoded.codec === MULTICODEC.P256_PUB) {
|
|
220
|
+
return verifyWebAuthnProof(
|
|
221
|
+
identity.signatures.publicKey,
|
|
222
|
+
identity.publicKey + identity.signatures.id,
|
|
223
|
+
decoded.keyBytes
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
decoded.codec === MULTICODEC.ED25519_PUB ||
|
|
229
|
+
decoded.codec === MULTICODEC.SECP256K1_PUB
|
|
230
|
+
) {
|
|
231
|
+
return bytesToHex(decoded.keyBytes) === identity.publicKey.toLowerCase();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return false;
|
|
235
|
+
}
|
package/src/webauthn/provider.js
CHANGED
|
@@ -14,20 +14,22 @@ import {
|
|
|
14
14
|
DID_KEY_PREFIX,
|
|
15
15
|
IDENTITY_TYPES,
|
|
16
16
|
KEYSTORE_ENCRYPTION_METHODS,
|
|
17
|
-
WEBAUTHN_CLIENT_DATA_TYPES,
|
|
18
17
|
} from '../constants.js';
|
|
19
18
|
import {
|
|
20
19
|
WebAuthnIdentityError,
|
|
21
20
|
WebAuthnAuthenticationError,
|
|
22
21
|
WebAuthnCredentialError,
|
|
23
22
|
WebAuthnNotSupportedError,
|
|
24
|
-
WebAuthnVerificationError,
|
|
25
23
|
} from '../errors.js';
|
|
26
24
|
import {
|
|
27
25
|
buildAuthenticatorSelection,
|
|
28
26
|
buildCredentialRequestOptions,
|
|
29
27
|
} from './config.js';
|
|
30
28
|
import { logWebAuthnResponse } from './debug-log.js';
|
|
29
|
+
import {
|
|
30
|
+
p256PublicKeyBytes,
|
|
31
|
+
verifyWebAuthnProof,
|
|
32
|
+
} from './proof-verification.js';
|
|
31
33
|
|
|
32
34
|
const webauthnLog = logger('orbitdb-identity-provider-webauthn-did:webauthn');
|
|
33
35
|
|
|
@@ -82,7 +84,11 @@ export class WebAuthnDIDProvider {
|
|
|
82
84
|
* Create a WebAuthn credential for OrbitDB identity
|
|
83
85
|
* This triggers biometric authentication (Face ID, Touch ID, Windows Hello, etc.)
|
|
84
86
|
* @param {Object} options - Credential options
|
|
85
|
-
* @param {string} options.userId -
|
|
87
|
+
* @param {string} options.userId - Account label shown in the credential
|
|
88
|
+
* picker (`user.name`). A label only: it does not identify the credential,
|
|
89
|
+
* two credentials may carry the same one, and it is never used to look one
|
|
90
|
+
* up. The handle the authenticator files the credential under is generated
|
|
91
|
+
* here and returned as `userHandle`.
|
|
86
92
|
* @param {string} options.displayName - Display name
|
|
87
93
|
* @param {string} options.domain - Domain/RP ID
|
|
88
94
|
* @param {boolean} options.encryptKeystore - Enable keystore encryption
|
|
@@ -117,7 +123,19 @@ export class WebAuthnDIDProvider {
|
|
|
117
123
|
|
|
118
124
|
// Generate challenge for credential creation
|
|
119
125
|
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
|
120
|
-
|
|
126
|
+
|
|
127
|
+
// The user handle is an opaque key, not a label. An authenticator stores
|
|
128
|
+
// one discoverable credential per (rp.id, user.id) pair and *replaces* the
|
|
129
|
+
// previous one when both match — silently, with no prompt and nothing to
|
|
130
|
+
// undo. Deriving the handle from a typed name therefore meant two people
|
|
131
|
+
// registering as "anna" on a shared device destroyed each other's passkey,
|
|
132
|
+
// and with it the DID and everything written under it (#45).
|
|
133
|
+
//
|
|
134
|
+
// 64 random bytes, as WebAuthn L2 §5.4.3 recommends. The same section
|
|
135
|
+
// forbids putting personally identifying information here, which a typed
|
|
136
|
+
// name or e-mail address plainly is. The name keeps its rightful place in
|
|
137
|
+
// `user.name` below, where the credential picker shows it.
|
|
138
|
+
const userHandle = crypto.getRandomValues(new Uint8Array(64));
|
|
121
139
|
|
|
122
140
|
webauthnLog('Calling navigator.credentials.create() for user: %s', userId);
|
|
123
141
|
|
|
@@ -130,7 +148,7 @@ export class WebAuthnDIDProvider {
|
|
|
130
148
|
id: domain,
|
|
131
149
|
},
|
|
132
150
|
user: {
|
|
133
|
-
id:
|
|
151
|
+
id: userHandle,
|
|
134
152
|
name: userId,
|
|
135
153
|
displayName,
|
|
136
154
|
},
|
|
@@ -228,6 +246,17 @@ export class WebAuthnDIDProvider {
|
|
|
228
246
|
hasY: !!publicKey.y,
|
|
229
247
|
});
|
|
230
248
|
|
|
249
|
+
// What the authenticator actually agreed to, as opposed to what the
|
|
250
|
+
// browser said it could negotiate. Only the ceremony settles this, and
|
|
251
|
+
// the raw PublicKeyCredential does not survive past this function — so
|
|
252
|
+
// read it here or lose the answer. Callers that offer a choice of
|
|
253
|
+
// keystore encryption method need it: a client can support largeBlob
|
|
254
|
+
// while the key in front of it does not.
|
|
255
|
+
const extensionSupport =
|
|
256
|
+
KeystoreEncryption.extensionSupportFromCredential(credential);
|
|
257
|
+
|
|
258
|
+
webauthnLog('Authenticator extension support: %o', extensionSupport);
|
|
259
|
+
|
|
231
260
|
const result = {
|
|
232
261
|
credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(
|
|
233
262
|
credential.rawId
|
|
@@ -236,10 +265,18 @@ export class WebAuthnDIDProvider {
|
|
|
236
265
|
publicKey,
|
|
237
266
|
userId,
|
|
238
267
|
displayName,
|
|
268
|
+
// Nothing in this package looks a credential up by handle — recovery
|
|
269
|
+
// goes through discoverable credentials, or through an explicit
|
|
270
|
+
// credential ID when those are switched off. It is surfaced anyway
|
|
271
|
+
// because it is the only copy the caller will ever see, and a flow
|
|
272
|
+
// that one day passes `allowCredentials` needs it. Decode with
|
|
273
|
+
// `WebAuthnDIDProvider.base64urlToArrayBuffer()`.
|
|
274
|
+
userHandle: WebAuthnDIDProvider.arrayBufferToBase64url(userHandle),
|
|
239
275
|
attestationObject: new Uint8Array(
|
|
240
276
|
credential.response.attestationObject
|
|
241
277
|
),
|
|
242
278
|
prfInput: prfInput || undefined,
|
|
279
|
+
extensionSupport,
|
|
243
280
|
};
|
|
244
281
|
|
|
245
282
|
webauthnLog('Credential creation completed successfully');
|
|
@@ -716,77 +753,27 @@ export class WebAuthnDIDProvider {
|
|
|
716
753
|
* @param {string} signatureData - Base64url signature envelope.
|
|
717
754
|
* @returns {Promise<boolean>} True if verification succeeds.
|
|
718
755
|
*/
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Check if credential ID matches
|
|
738
|
-
webauthnLog('Verification step: checking credential ID');
|
|
739
|
-
if (proof.credentialId !== this.credentialId) {
|
|
740
|
-
webauthnLog.error(
|
|
741
|
-
'Credential ID mismatch in WebAuthn proof verification'
|
|
742
|
-
);
|
|
743
|
-
throw new WebAuthnVerificationError('Credential ID mismatch');
|
|
744
|
-
}
|
|
745
|
-
webauthnLog('Verification step: credential ID check PASSED');
|
|
746
|
-
|
|
747
|
-
// Verify client data JSON
|
|
748
|
-
webauthnLog('Verification step: checking client data');
|
|
749
|
-
if (proof.clientDataJSON) {
|
|
750
|
-
const clientData = JSON.parse(proof.clientDataJSON);
|
|
751
|
-
if (clientData.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) {
|
|
752
|
-
webauthnLog.error('Invalid WebAuthn proof type: %s', clientData.type);
|
|
753
|
-
throw new WebAuthnVerificationError('Invalid WebAuthn proof type');
|
|
754
|
-
}
|
|
755
|
-
webauthnLog('Verification step: client data check PASSED');
|
|
756
|
-
} else {
|
|
757
|
-
webauthnLog.error('Invalid client data in WebAuthn proof');
|
|
758
|
-
throw new WebAuthnVerificationError('Invalid client data');
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// No expiry check. This proof is embedded in an OrbitDB identity
|
|
762
|
-
// document, and every entry ever signed under that document has to stay
|
|
763
|
-
// validatable — an expiring proof would silently invalidate history.
|
|
764
|
-
// The proof attests that this credential signed this payload, which does
|
|
765
|
-
// not stop being true. Proofs written by earlier versions still carry a
|
|
766
|
-
// timestamp field; it is simply ignored.
|
|
767
|
-
|
|
768
|
-
// Verify authenticator data exists
|
|
769
|
-
webauthnLog('Verification step: checking authenticator data');
|
|
770
|
-
if (!proof.authenticatorData) {
|
|
771
|
-
webauthnLog.error('Missing authenticator data in WebAuthn proof');
|
|
772
|
-
throw new WebAuthnVerificationError('Missing authenticator data');
|
|
773
|
-
}
|
|
774
|
-
webauthnLog('Verification step: authenticator data check PASSED');
|
|
775
|
-
|
|
776
|
-
webauthnLog('Verification result: SUCCESS');
|
|
777
|
-
return true;
|
|
778
|
-
} catch (error) {
|
|
779
|
-
webauthnLog.error(
|
|
780
|
-
'WebAuthn proof verification failed: %s',
|
|
781
|
-
error.message
|
|
782
|
-
);
|
|
756
|
+
/**
|
|
757
|
+
* Verify a proof from `sign(data)` against a P-256 key.
|
|
758
|
+
*
|
|
759
|
+
* This used to check that the proof had the right fields and return true —
|
|
760
|
+
* no signature, no challenge — so any proof-shaped JSON passed
|
|
761
|
+
* (GHSA-326j-4cc3-4rrg).
|
|
762
|
+
*
|
|
763
|
+
* @param {string} signatureData base64url proof from `sign()`
|
|
764
|
+
* @param {string|Uint8Array} data the signed data
|
|
765
|
+
* @param {Uint8Array|{x: ArrayLike<number>, y: ArrayLike<number>}} publicKey P-256 key
|
|
766
|
+
* @returns {Promise<boolean>}
|
|
767
|
+
*/
|
|
768
|
+
async verify(signatureData, data, publicKey) {
|
|
769
|
+
const keyBytes = p256PublicKeyBytes(publicKey);
|
|
770
|
+
if (!keyBytes || data === undefined) {
|
|
771
|
+
webauthnLog('verify() needs the signed data and a P-256 public key');
|
|
783
772
|
return false;
|
|
784
773
|
}
|
|
774
|
+
return verifyWebAuthnProof(signatureData, data, keyBytes);
|
|
785
775
|
}
|
|
786
776
|
|
|
787
|
-
/**
|
|
788
|
-
* Utility: Convert ArrayBuffer to base64url
|
|
789
|
-
*/
|
|
790
777
|
static arrayBufferToBase64url(buffer) {
|
|
791
778
|
const bytes = new Uint8Array(buffer);
|
|
792
779
|
let binary = '';
|
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;
|