@le-space/orbitdb-identity-provider-webauthn-did 0.5.1 → 0.5.3
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 +71 -0
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/keystore/derived-signing-key.js +50 -12
- package/src/keystore/provider.js +45 -25
- package/src/varsig/assertion.js +2 -38
- package/src/varsig/credential.js +0 -20
- package/src/varsig/identity.js +21 -21
- package/src/webauthn/proof-verification.js +235 -0
- package/src/webauthn/provider.js +21 -69
- package/types/keystore.d.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.3
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
|
|
9
|
+
- **Varsig identities could still be forged through
|
|
10
|
+
`createWebAuthnVarsigIdentities()`**
|
|
11
|
+
([GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg),
|
|
12
|
+
left open by 0.5.2). 0.5.2 bound a varsig identity's id to the key that
|
|
13
|
+
signs for it, but only in `verifyVarsigIdentity`. The verifier returned by
|
|
14
|
+
`createWebAuthnVarsigIdentities()` — what OrbitDB's access controller
|
|
15
|
+
calls when an app builds its identities with it, as the varsig demo does —
|
|
16
|
+
still checked the two signatures against whatever key the identity
|
|
17
|
+
carried, so a second user's passkey on the same origin could sign someone
|
|
18
|
+
else's DID and be accepted as a writer under it. It now delegates to
|
|
19
|
+
`verifyVarsigIdentity`. orbitdb-relay is not affected: it registers
|
|
20
|
+
`verifyVarsigIdentity` directly.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `signingKeyType: 'Ed25519'` derives the OrbitDB signing key as Ed25519
|
|
25
|
+
instead of secp256k1 — still from the passkey's PRF output (HKDF-SHA256,
|
|
26
|
+
domain-separated by DID and key type), so one passkey still yields one
|
|
27
|
+
identity document on every device. The default stays secp256k1, and a
|
|
28
|
+
keystore that already holds a key for the DID keeps it.
|
|
29
|
+
|
|
30
|
+
## 0.5.2
|
|
31
|
+
|
|
32
|
+
### Security
|
|
33
|
+
|
|
34
|
+
- **Identity verification checked shapes, not signatures**
|
|
35
|
+
([GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg)).
|
|
36
|
+
Four checks that decide whether an identity may speak for a DID verified
|
|
37
|
+
nothing: the static `verifyIdentity` OrbitDB calls accepted any `did:key:` of
|
|
38
|
+
type `webauthn`; `WebAuthnDIDProvider.verify()` accepted any proof with the
|
|
39
|
+
right fields; varsig verification returned `true` whenever
|
|
40
|
+
`window.__PLAYWRIGHT__` was set; and `verifyVarsigIdentity` never compared the
|
|
41
|
+
id with the key signing for it. Anyone who knew a DID could write as it
|
|
42
|
+
wherever write access was granted to that DID.
|
|
43
|
+
|
|
44
|
+
`src/webauthn/proof-verification.js` now decides it. A credential DID (P-256)
|
|
45
|
+
is bound through the WebAuthn assertion in `signatures.publicKey`, verified
|
|
46
|
+
against the key the DID itself encodes. A keystore DID (Ed25519, secp256k1)
|
|
47
|
+
must be exactly the key that signs. A varsig identity must carry the DID of
|
|
48
|
+
its own key. The test-mode branches are gone from `src/`.
|
|
49
|
+
|
|
50
|
+
**Check before upgrading** — three things that were accepted and now are not:
|
|
51
|
+
- Entries written under `useKeystoreDID` by 0.5.1 or earlier. Those identities
|
|
52
|
+
signed with a key unrelated to their DID (see _Fixed_), which makes their
|
|
53
|
+
entries indistinguishable from forgeries, so upgraded peers refuse them.
|
|
54
|
+
Identities with the default credential DID are unaffected.
|
|
55
|
+
- Legacy 64-hex identity ids. They cannot be bound to any key.
|
|
56
|
+
- `WebAuthnDIDProvider.verify(signature)` with one argument. It now needs the
|
|
57
|
+
signed data and the public key: `verify(signature, data, publicKey)`.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- `useKeystoreDID` with `keystoreKeyType: 'Ed25519'` signed entries with a
|
|
62
|
+
secp256k1 key. The provider stored its key under the credential's P-256 DID;
|
|
63
|
+
OrbitDB looked it up under the keystore DID, found nothing and generated a
|
|
64
|
+
fresh secp256k1 key, so only the DID was Ed25519. The key now goes under the
|
|
65
|
+
DID OrbitDB looks it up by, encrypted and unencrypted.
|
|
66
|
+
- The Ed25519 demo kept `prfInput` across a reload as a plain object, which the
|
|
67
|
+
browser refused as a PRF input; the worker's seed then fell back to the
|
|
68
|
+
credential ID and its archive could no longer be opened.
|
|
69
|
+
|
|
70
|
+
### Tests
|
|
71
|
+
|
|
72
|
+
- `tests/identity-forgery.test.js`: every forgery above is refused, and beside
|
|
73
|
+
each the genuine identity still passes. The browser suites use a Chromium
|
|
74
|
+
virtual authenticator instead of mocks that signed with 64 zero bytes.
|
|
75
|
+
|
|
5
76
|
## 0.5.1
|
|
6
77
|
|
|
7
78
|
### Fixed
|
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ This package provides:
|
|
|
10
10
|
- A standalone WebAuthn toolkit export (`@le-space/orbitdb-identity-provider-webauthn-did/standalone`) for reuse outside OrbitDB identity wiring.
|
|
11
11
|
- A keystore helper export (`@le-space/orbitdb-identity-provider-webauthn-did/keystore`) for encrypted keystore utilities and provider wiring.
|
|
12
12
|
- **WebAuthn-Varsig**: No insecure OrbitDB keystore at all. Each entry is signed by WebAuthn (varsig envelope), so keys never leave the authenticator, one Passkey (WebAuthn) prompt per write.
|
|
13
|
+
- **Passkey DID with a derived signing key** (the default): the DID is the credential's own P-256 key, and OrbitDB signs entries with a key derived from the passkey's PRF output (HKDF-SHA256, domain-separated by the DID) — secp256k1 by default, Ed25519 with `signingKeyType: 'Ed25519'`. The same passkey yields the same identity document on every device. That key lives in OrbitDB's keystore and is **not** covered by `encryptKeystore`; without PRF the keystore generates one, which then stays on that device.
|
|
13
14
|
- **Keystore-based DID**: Generates an Ed25519/secp256k1 keystore keypair for OrbitDB signing in browser memory. When `encryptKeystore` is enabled, the private key is encrypted with AES-GCM and only rehydrated in memory after a WebAuthn unlock (PRF, largeBlob, or hmac-secret).
|
|
14
15
|
|
|
15
16
|
## Current WebAuthn Model
|
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.3",
|
|
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",
|
|
@@ -21,12 +21,19 @@
|
|
|
21
21
|
* Needs no change to `@orbitdb/core` — it only has to happen before
|
|
22
22
|
* `getKey(id)` is called, which `getId()` is.
|
|
23
23
|
*
|
|
24
|
+
* The key is secp256k1, the type `createKey` would have produced, unless the
|
|
25
|
+
* provider is created with `signingKeyType: 'Ed25519'`. OrbitDB's keystore
|
|
26
|
+
* reads the type from the raw key, so either signs and verifies unchanged.
|
|
27
|
+
*
|
|
24
28
|
* See issue #18, option 1.
|
|
25
29
|
*/
|
|
26
30
|
import { logger } from '@libp2p/logger';
|
|
27
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
generateKeyPairFromSeed,
|
|
33
|
+
privateKeyFromRaw,
|
|
34
|
+
} from '@libp2p/crypto/keys';
|
|
28
35
|
|
|
29
|
-
import { CRYPTO_ALGORITHMS } from '../constants.js';
|
|
36
|
+
import { CRYPTO_ALGORITHMS, KEY_TYPES } from '../constants.js';
|
|
30
37
|
import { buildCredentialRequestOptions } from '../webauthn/config.js';
|
|
31
38
|
|
|
32
39
|
const log = logger('orbitdb-identity-provider-webauthn-did:derived-key');
|
|
@@ -64,17 +71,35 @@ async function hkdf(seed, info) {
|
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
/**
|
|
67
|
-
* Turn a PRF seed into a
|
|
74
|
+
* Turn a PRF seed into a private key of the requested type, as the raw bytes
|
|
75
|
+
* `keystore.addKey` takes.
|
|
76
|
+
*
|
|
77
|
+
* secp256k1: values outside the curve order are invalid. That is vanishingly
|
|
78
|
+
* unlikely for a 256-bit hash, but retrying with a counter keeps the
|
|
79
|
+
* derivation total and deterministic rather than probabilistic.
|
|
68
80
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
81
|
+
* Ed25519: every 32 bytes are a valid seed, so there is nothing to retry. The
|
|
82
|
+
* key type is part of its info string, so the two derivations never share
|
|
83
|
+
* key material, even for the same passkey and DID.
|
|
72
84
|
*
|
|
73
85
|
* @param {Uint8Array} seed - PRF output.
|
|
74
86
|
* @param {string} did - Identity DID, mixed in for domain separation.
|
|
75
|
-
* @
|
|
87
|
+
* @param {string} [keyType='secp256k1'] - 'secp256k1' or 'Ed25519'.
|
|
88
|
+
* @returns {Promise<Uint8Array>} The 32-byte secp256k1 scalar, or the 64-byte
|
|
89
|
+
* Ed25519 private key (seed ‖ public key) libp2p expects.
|
|
76
90
|
*/
|
|
77
|
-
export async function deriveSigningKeyBytes(
|
|
91
|
+
export async function deriveSigningKeyBytes(
|
|
92
|
+
seed,
|
|
93
|
+
did,
|
|
94
|
+
keyType = KEY_TYPES.SECP256K1
|
|
95
|
+
) {
|
|
96
|
+
if (keyType === KEY_TYPES.ED25519) {
|
|
97
|
+
const edSeed = await hkdf(seed, `${DERIVATION_INFO}:ed25519:${did}`);
|
|
98
|
+
return (await generateKeyPairFromSeed(KEY_TYPES.ED25519, edSeed)).raw;
|
|
99
|
+
}
|
|
100
|
+
if (keyType !== KEY_TYPES.SECP256K1) {
|
|
101
|
+
throw new Error(`unsupported signing key type: ${keyType}`);
|
|
102
|
+
}
|
|
78
103
|
for (let counter = 0; counter < 256; counter++) {
|
|
79
104
|
const candidate = await hkdf(seed, `${DERIVATION_INFO}:${did}:${counter}`);
|
|
80
105
|
try {
|
|
@@ -158,6 +183,7 @@ export async function getPrfOutput(credential, { rpId } = {}) {
|
|
|
158
183
|
* @param {string} params.did - Identity DID.
|
|
159
184
|
* @param {Object} params.credential - Stored WebAuthn credential info.
|
|
160
185
|
* @param {string} [params.rpId] - Relying party id.
|
|
186
|
+
* @param {string} [params.keyType='secp256k1'] - Type of key to derive.
|
|
161
187
|
* @returns {Promise<'derived'|'existing'|'unavailable'>} What happened.
|
|
162
188
|
*/
|
|
163
189
|
export async function ensureDerivedSigningKey({
|
|
@@ -165,12 +191,24 @@ export async function ensureDerivedSigningKey({
|
|
|
165
191
|
did,
|
|
166
192
|
credential,
|
|
167
193
|
rpId,
|
|
194
|
+
keyType = KEY_TYPES.SECP256K1,
|
|
168
195
|
}) {
|
|
169
196
|
if (!keystore || !did || !credential) return 'unavailable';
|
|
170
197
|
|
|
171
198
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
199
|
+
const existing = await keystore.getKey(did);
|
|
200
|
+
if (existing) {
|
|
201
|
+
if (existing.type !== keyType) {
|
|
202
|
+
// Still left alone: a key of the other type has history behind it,
|
|
203
|
+
// and replacing it would mint a second document for the DID.
|
|
204
|
+
log(
|
|
205
|
+
'keystore already holds a %s key for this DID, not the requested %s; leaving it alone',
|
|
206
|
+
existing.type,
|
|
207
|
+
keyType
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
log('keystore already holds a key for this DID; leaving it alone');
|
|
211
|
+
}
|
|
174
212
|
return 'existing';
|
|
175
213
|
}
|
|
176
214
|
} catch (error) {
|
|
@@ -187,9 +225,9 @@ export async function ensureDerivedSigningKey({
|
|
|
187
225
|
}
|
|
188
226
|
|
|
189
227
|
try {
|
|
190
|
-
const privateKey = await deriveSigningKeyBytes(seed, did);
|
|
228
|
+
const privateKey = await deriveSigningKeyBytes(seed, did, keyType);
|
|
191
229
|
await keystore.addKey(did, { privateKey });
|
|
192
|
-
log('seeded the keystore with a PRF-derived signing key');
|
|
230
|
+
log('seeded the keystore with a PRF-derived %s signing key', keyType);
|
|
193
231
|
return 'derived';
|
|
194
232
|
} catch (error) {
|
|
195
233
|
log('failed to seed the derived key: %s', error.message);
|
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,
|
|
@@ -49,6 +50,11 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
49
50
|
* @param {string} [options.keystoreKeyType='secp256k1'] - Keystore key type.
|
|
50
51
|
* @param {boolean} [options.encryptKeystore=false] - Encrypt keystore at rest.
|
|
51
52
|
* @param {string} [options.keystoreEncryptionMethod='prf'] - Encryption method.
|
|
53
|
+
* @param {boolean} [options.deriveSigningKeyFromPrf=true] - Derive the OrbitDB
|
|
54
|
+
* signing key from the passkey's PRF output instead of a random one.
|
|
55
|
+
* @param {string} [options.signingKeyType='secp256k1'] - Type of that derived
|
|
56
|
+
* key: 'secp256k1' or 'Ed25519'. `useKeystoreDID` has its own
|
|
57
|
+
* `keystoreKeyType`.
|
|
52
58
|
*/
|
|
53
59
|
constructor({
|
|
54
60
|
webauthnCredential,
|
|
@@ -58,8 +64,15 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
58
64
|
encryptKeystore = false,
|
|
59
65
|
keystoreEncryptionMethod = KEYSTORE_ENCRYPTION_METHODS.PRF,
|
|
60
66
|
deriveSigningKeyFromPrf = true,
|
|
67
|
+
signingKeyType = KEY_TYPES.SECP256K1,
|
|
61
68
|
}) {
|
|
69
|
+
if (![KEY_TYPES.SECP256K1, KEY_TYPES.ED25519].includes(signingKeyType)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`signingKeyType must be '${KEY_TYPES.SECP256K1}' or '${KEY_TYPES.ED25519}', not '${signingKeyType}'`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
62
74
|
this.credential = webauthnCredential;
|
|
75
|
+
this.signingKeyType = signingKeyType;
|
|
63
76
|
// Costs one extra assertion the first time an identity is created on a
|
|
64
77
|
// device. Set false to keep a keystore-generated key instead.
|
|
65
78
|
this.deriveSigningKeyFromPrf = deriveSigningKeyFromPrf;
|
|
@@ -117,6 +130,7 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
117
130
|
keystore,
|
|
118
131
|
did,
|
|
119
132
|
credential: this.credential,
|
|
133
|
+
keyType: this.signingKeyType,
|
|
120
134
|
});
|
|
121
135
|
identityLog('Derived signing key: %s', outcome);
|
|
122
136
|
}
|
|
@@ -171,12 +185,24 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
171
185
|
const publicKeyBytes = normalizeByteArray(encryptedData.publicKey);
|
|
172
186
|
const keyType = encryptedData.keyType || this.keystoreKeyType;
|
|
173
187
|
|
|
174
|
-
|
|
188
|
+
const did = this.createDIDFromKeystorePublicKey(
|
|
175
189
|
publicKeyBytes,
|
|
176
190
|
keyType,
|
|
177
191
|
varint,
|
|
178
192
|
base58btc
|
|
179
193
|
);
|
|
194
|
+
// OrbitDB signs with whatever key it finds under the id, and creates a
|
|
195
|
+
// secp256k1 one when there is none — so without this the DID said
|
|
196
|
+
// Ed25519 and the entries were signed by an unrelated key.
|
|
197
|
+
if (
|
|
198
|
+
this.unlockedKeypair?.privateKey &&
|
|
199
|
+
!(await this.keystore.getKey(did))
|
|
200
|
+
) {
|
|
201
|
+
await this.keystore.addKey(did, {
|
|
202
|
+
privateKey: this.unlockedKeypair.privateKey,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return did;
|
|
180
206
|
}
|
|
181
207
|
|
|
182
208
|
// Get the keystore's identity ID (this will be used to retrieve the key)
|
|
@@ -293,12 +319,18 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
293
319
|
keystoreKey.type
|
|
294
320
|
);
|
|
295
321
|
|
|
296
|
-
|
|
322
|
+
const did = this.createDIDFromKeystorePublicKey(
|
|
297
323
|
publicKeyBytes,
|
|
298
324
|
keystoreKey.type,
|
|
299
325
|
varint,
|
|
300
326
|
base58btc
|
|
301
327
|
);
|
|
328
|
+
// Stored above under the credential's P-256 DID; OrbitDB looks it up under
|
|
329
|
+
// the DID returned here, found nothing, and generated a secp256k1 key.
|
|
330
|
+
if (!(await this.keystore.getKey(did))) {
|
|
331
|
+
await this.keystore.addKey(did, { privateKey: keystoreKey.raw });
|
|
332
|
+
}
|
|
333
|
+
return did;
|
|
302
334
|
} catch (error) {
|
|
303
335
|
identityLog.error(
|
|
304
336
|
'Failed to create Ed25519 DID from keystore: %s',
|
|
@@ -633,6 +665,8 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
633
665
|
keystoreKeyType = KEY_TYPES.SECP256K1,
|
|
634
666
|
encryptKeystore = false,
|
|
635
667
|
keystoreEncryptionMethod = KEYSTORE_ENCRYPTION_METHODS.PRF,
|
|
668
|
+
deriveSigningKeyFromPrf = true,
|
|
669
|
+
signingKeyType = KEY_TYPES.SECP256K1,
|
|
636
670
|
} = options;
|
|
637
671
|
|
|
638
672
|
identityLog(
|
|
@@ -649,6 +683,8 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
649
683
|
keystoreKeyType,
|
|
650
684
|
encryptKeystore,
|
|
651
685
|
keystoreEncryptionMethod,
|
|
686
|
+
deriveSigningKeyFromPrf,
|
|
687
|
+
signingKeyType,
|
|
652
688
|
});
|
|
653
689
|
|
|
654
690
|
// If encryption is enabled, create and unlock encrypted keystore
|
|
@@ -720,6 +756,7 @@ export class OrbitDBWebAuthnIdentityProvider {
|
|
|
720
756
|
* @param {string} options.keystoreKeyType - Key type for keystore: 'secp256k1' (default) or 'Ed25519'
|
|
721
757
|
* @param {boolean} options.encryptKeystore - If true, encrypts the keystore with WebAuthn-protected secret
|
|
722
758
|
* @param {string} options.keystoreEncryptionMethod - Encryption method: 'largeBlob' or 'hmac-secret'
|
|
759
|
+
* @param {string} [options.signingKeyType] - PRF-derived signing key type: 'secp256k1' (default) or 'Ed25519'
|
|
723
760
|
* @returns {Function} Provider factory for OrbitDB.
|
|
724
761
|
*/
|
|
725
762
|
export function OrbitDBWebAuthnIdentityProviderFunction(options = {}) {
|
|
@@ -734,27 +771,10 @@ OrbitDBWebAuthnIdentityProviderFunction.type = IDENTITY_TYPES.WEBAUTHN;
|
|
|
734
771
|
OrbitDBWebAuthnIdentityProviderFunction.verifyIdentity = async function (
|
|
735
772
|
identity
|
|
736
773
|
) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// so we can trust it if it has the right structure
|
|
744
|
-
// Accept both DID format (did:key:...) and hash format (hex string) for backward compatibility
|
|
745
|
-
const isValidDID = identity.id && identity.id.startsWith(DID_KEY_PREFIX);
|
|
746
|
-
const isValidHash = identity.id && /^[a-f0-9]{64}$/.test(identity.id); // 64-char hex string (legacy)
|
|
747
|
-
|
|
748
|
-
if (
|
|
749
|
-
identity.type === IDENTITY_TYPES.WEBAUTHN &&
|
|
750
|
-
(isValidDID || isValidHash)
|
|
751
|
-
) {
|
|
752
|
-
return true;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
return false;
|
|
756
|
-
} catch (error) {
|
|
757
|
-
console.error('WebAuthn static identity verification failed:', error);
|
|
758
|
-
return false;
|
|
759
|
-
}
|
|
774
|
+
// The check OrbitDB relies on to decide whether this identity may speak for
|
|
775
|
+
// its DID. It used to accept any `did:key:` of type `webauthn`, so a peer
|
|
776
|
+
// holding only its own key could write as anyone in a write list
|
|
777
|
+
// (GHSA-326j-4cc3-4rrg). See ../webauthn/proof-verification.js.
|
|
778
|
+
if (identity?.type !== IDENTITY_TYPES.WEBAUTHN) return false;
|
|
779
|
+
return verifyWebAuthnIdentityBinding(identity);
|
|
760
780
|
};
|
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;
|
|
@@ -193,26 +193,11 @@ export function createWebAuthnVarsigIdentities(identity, domainLabels = {}) {
|
|
|
193
193
|
return false;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
labels.id
|
|
202
|
-
);
|
|
203
|
-
if (!idValid) return false;
|
|
204
|
-
|
|
205
|
-
const publicKeyPayload = concat([
|
|
206
|
-
identityToVerify.publicKey,
|
|
207
|
-
identityToVerify.signatures.id,
|
|
208
|
-
]);
|
|
209
|
-
|
|
210
|
-
return verifyVarsigForPayload(
|
|
211
|
-
identityToVerify.signatures.publicKey,
|
|
212
|
-
identityToVerify.publicKey,
|
|
213
|
-
publicKeyPayload,
|
|
214
|
-
labels.publicKey
|
|
215
|
-
);
|
|
196
|
+
// The same check OrbitDB's provider registry uses, including the binding
|
|
197
|
+
// of the id to the key that signs for it. Verifying only the two
|
|
198
|
+
// signatures, as this did, accepted a stranger's passkey signing someone
|
|
199
|
+
// else's DID (GHSA-326j-4cc3-4rrg, left open here in 0.5.2).
|
|
200
|
+
return verifyVarsigIdentity(identityToVerify, labels);
|
|
216
201
|
};
|
|
217
202
|
|
|
218
203
|
const getIdentity = async (hash) => {
|
|
@@ -330,6 +315,21 @@ export async function decodeVarsigIdentityFromBytes(bytes, domainLabels = {}) {
|
|
|
330
315
|
*/
|
|
331
316
|
export async function verifyVarsigIdentity(identity, domainLabels = {}) {
|
|
332
317
|
if (!identity || !identity.publicKey || !identity.signatures) return false;
|
|
318
|
+
|
|
319
|
+
// The id has to be the DID of the key that signs for it. Both signatures
|
|
320
|
+
// below are checked against `identity.publicKey`, so without this anyone
|
|
321
|
+
// with a passkey on the same origin could sign a stranger's DID with their
|
|
322
|
+
// own key and pass.
|
|
323
|
+
let expectedDid;
|
|
324
|
+
try {
|
|
325
|
+
expectedDid = DIDKey.fromPublicKey(
|
|
326
|
+
algorithmFromPublicKey(identity.publicKey),
|
|
327
|
+
identity.publicKey
|
|
328
|
+
).did;
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
if (identity.id !== expectedDid) return false;
|
|
333
333
|
const labels = { ...DEFAULT_DOMAIN_LABELS, ...domainLabels };
|
|
334
334
|
|
|
335
335
|
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
|
|
|
@@ -751,77 +753,27 @@ export class WebAuthnDIDProvider {
|
|
|
751
753
|
* @param {string} signatureData - Base64url signature envelope.
|
|
752
754
|
* @returns {Promise<boolean>} True if verification succeeds.
|
|
753
755
|
*/
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Check if credential ID matches
|
|
773
|
-
webauthnLog('Verification step: checking credential ID');
|
|
774
|
-
if (proof.credentialId !== this.credentialId) {
|
|
775
|
-
webauthnLog.error(
|
|
776
|
-
'Credential ID mismatch in WebAuthn proof verification'
|
|
777
|
-
);
|
|
778
|
-
throw new WebAuthnVerificationError('Credential ID mismatch');
|
|
779
|
-
}
|
|
780
|
-
webauthnLog('Verification step: credential ID check PASSED');
|
|
781
|
-
|
|
782
|
-
// Verify client data JSON
|
|
783
|
-
webauthnLog('Verification step: checking client data');
|
|
784
|
-
if (proof.clientDataJSON) {
|
|
785
|
-
const clientData = JSON.parse(proof.clientDataJSON);
|
|
786
|
-
if (clientData.type !== WEBAUTHN_CLIENT_DATA_TYPES.GET) {
|
|
787
|
-
webauthnLog.error('Invalid WebAuthn proof type: %s', clientData.type);
|
|
788
|
-
throw new WebAuthnVerificationError('Invalid WebAuthn proof type');
|
|
789
|
-
}
|
|
790
|
-
webauthnLog('Verification step: client data check PASSED');
|
|
791
|
-
} else {
|
|
792
|
-
webauthnLog.error('Invalid client data in WebAuthn proof');
|
|
793
|
-
throw new WebAuthnVerificationError('Invalid client data');
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// No expiry check. This proof is embedded in an OrbitDB identity
|
|
797
|
-
// document, and every entry ever signed under that document has to stay
|
|
798
|
-
// validatable — an expiring proof would silently invalidate history.
|
|
799
|
-
// The proof attests that this credential signed this payload, which does
|
|
800
|
-
// not stop being true. Proofs written by earlier versions still carry a
|
|
801
|
-
// timestamp field; it is simply ignored.
|
|
802
|
-
|
|
803
|
-
// Verify authenticator data exists
|
|
804
|
-
webauthnLog('Verification step: checking authenticator data');
|
|
805
|
-
if (!proof.authenticatorData) {
|
|
806
|
-
webauthnLog.error('Missing authenticator data in WebAuthn proof');
|
|
807
|
-
throw new WebAuthnVerificationError('Missing authenticator data');
|
|
808
|
-
}
|
|
809
|
-
webauthnLog('Verification step: authenticator data check PASSED');
|
|
810
|
-
|
|
811
|
-
webauthnLog('Verification result: SUCCESS');
|
|
812
|
-
return true;
|
|
813
|
-
} catch (error) {
|
|
814
|
-
webauthnLog.error(
|
|
815
|
-
'WebAuthn proof verification failed: %s',
|
|
816
|
-
error.message
|
|
817
|
-
);
|
|
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');
|
|
818
772
|
return false;
|
|
819
773
|
}
|
|
774
|
+
return verifyWebAuthnProof(signatureData, data, keyBytes);
|
|
820
775
|
}
|
|
821
776
|
|
|
822
|
-
/**
|
|
823
|
-
* Utility: Convert ArrayBuffer to base64url
|
|
824
|
-
*/
|
|
825
777
|
static arrayBufferToBase64url(buffer) {
|
|
826
778
|
const bytes = new Uint8Array(buffer);
|
|
827
779
|
let binary = '';
|
package/types/keystore.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export interface OrbitDBWebAuthnIdentityProviderOptions {
|
|
|
48
48
|
keystoreKeyType?: string;
|
|
49
49
|
encryptKeystore?: boolean;
|
|
50
50
|
keystoreEncryptionMethod?: KeystoreEncryptionMethod;
|
|
51
|
+
/** Derive the OrbitDB signing key from the passkey's PRF output (default true). */
|
|
52
|
+
deriveSigningKeyFromPrf?: boolean;
|
|
53
|
+
/** Type of that derived key (default 'secp256k1'). */
|
|
54
|
+
signingKeyType?: 'secp256k1' | 'Ed25519';
|
|
51
55
|
[key: string]: unknown;
|
|
52
56
|
}
|
|
53
57
|
|