@le-space/orbitdb-identity-provider-webauthn-did 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.1
6
+
7
+ ### Added
8
+
9
+ - Derive the OrbitDB signing key from the passkey PRF output instead of letting
10
+ the keystore generate a random one. `Identities.createIdentity` takes
11
+ `keystore.getKey(id) || keystore.createKey(id)`, and `createKey` is random, so
12
+ the same passkey previously produced a different `publicKey` and
13
+ `signatures.id` — and therefore a different identity document — on every
14
+ device. Seeding the keystore during `getId()`, the last point before OrbitDB
15
+ asks for the key, makes the whole document reproducible: one block to keep
16
+ retrievable for a DID instead of one per device, and a device that loses its
17
+ keystore while keeping the passkey reconstructs the same identity rather than
18
+ minting another. Needs no change to `@orbitdb/core`.
19
+ - `createCredential()` now requests the PRF extension even when the keystore is
20
+ not encrypted, and stores the input. Without a stored input the output would
21
+ differ per call, so a credential registered without one can never get a
22
+ reproducible identity. Requesting it is harmless where unsupported.
23
+
24
+ ### Notes
25
+
26
+ - Falls back cleanly. No PRF support, no stored input, or a refused assertion
27
+ leaves the keystore to generate its own key — 0.4.0 behaviour, stable per
28
+ device but not across devices. Opt out with
29
+ `deriveSigningKeyFromPrf: false`.
30
+ - An existing keystore key is never replaced. Swapping it under a device that
31
+ already has history would mint a second document for the DID, which is what
32
+ this avoids. Installs upgrading from 0.4.0 keep their key; fresh installs get
33
+ a reproducible identity.
34
+ - Costs one extra assertion the first time an identity is created on a device.
35
+ The key is persisted, so later loads do not repeat it.
36
+
5
37
  ## 0.4.0
6
38
 
7
39
  ### Breaking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "WebAuthn-based DID identity provider for OrbitDB for hardware-secured wallets and biometric Passkey authentication",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -0,0 +1,198 @@
1
+ /**
2
+ * @module DerivedSigningKey
3
+ * @description
4
+ * Derives the OrbitDB signing key deterministically from the passkey's PRF
5
+ * output instead of letting the keystore generate a random one.
6
+ *
7
+ * `Identities.createIdentity` in `@orbitdb/core` does:
8
+ *
9
+ * const privateKey = await keystore.getKey(id) || await keystore.createKey(id)
10
+ * const publicKey = keystore.getPublic(privateKey)
11
+ * const idSignature = await signMessage(privateKey, id)
12
+ *
13
+ * `createKey` generates a random secp256k1 key, so `publicKey` and
14
+ * `signatures.id` differ on every device even for the same passkey — one
15
+ * identity document per device. Seeding the keystore with a PRF-derived key
16
+ * before OrbitDB asks for it makes all of that reproducible: the same passkey
17
+ * yields the same document everywhere, so there is exactly one block to keep
18
+ * retrievable and a device that loses its keystore reconstructs the identity
19
+ * rather than minting another one.
20
+ *
21
+ * Needs no change to `@orbitdb/core` — it only has to happen before
22
+ * `getKey(id)` is called, which `getId()` is.
23
+ *
24
+ * See issue #18, option 1.
25
+ */
26
+ import { logger } from '@libp2p/logger';
27
+ import { privateKeyFromRaw } from '@libp2p/crypto/keys';
28
+
29
+ import { CRYPTO_ALGORITHMS } from '../constants.js';
30
+ import { buildCredentialRequestOptions } from '../webauthn/config.js';
31
+
32
+ const log = logger('orbitdb-identity-provider-webauthn-did:derived-key');
33
+
34
+ // Bumping this rotates every derived key, so treat it as a breaking change.
35
+ const DERIVATION_INFO = 'orbitdb-identity-provider-webauthn-did:signing-key:v1';
36
+
37
+ const SECP256K1_KEY_BYTES = 32;
38
+
39
+ /**
40
+ * HKDF-SHA256 a 32-byte candidate from the PRF seed.
41
+ * @param {Uint8Array} seed - PRF output.
42
+ * @param {string} info - Domain separation string.
43
+ * @returns {Promise<Uint8Array>} 32 bytes.
44
+ */
45
+ async function hkdf(seed, info) {
46
+ const baseKey = await crypto.subtle.importKey(
47
+ 'raw',
48
+ seed,
49
+ CRYPTO_ALGORITHMS.HKDF,
50
+ false,
51
+ ['deriveBits']
52
+ );
53
+ const bits = await crypto.subtle.deriveBits(
54
+ {
55
+ name: CRYPTO_ALGORITHMS.HKDF,
56
+ hash: CRYPTO_ALGORITHMS.SHA_256,
57
+ salt: new Uint8Array(0),
58
+ info: new TextEncoder().encode(info),
59
+ },
60
+ baseKey,
61
+ SECP256K1_KEY_BYTES * 8
62
+ );
63
+ return new Uint8Array(bits);
64
+ }
65
+
66
+ /**
67
+ * Turn a PRF seed into a valid secp256k1 private key.
68
+ *
69
+ * Values outside the curve order are invalid. That is vanishingly unlikely for
70
+ * a 256-bit hash, but retrying with a counter keeps the derivation total and
71
+ * deterministic rather than probabilistic.
72
+ *
73
+ * @param {Uint8Array} seed - PRF output.
74
+ * @param {string} did - Identity DID, mixed in for domain separation.
75
+ * @returns {Promise<Uint8Array>} A valid 32-byte secp256k1 private key.
76
+ */
77
+ export async function deriveSigningKeyBytes(seed, did) {
78
+ for (let counter = 0; counter < 256; counter++) {
79
+ const candidate = await hkdf(seed, `${DERIVATION_INFO}:${did}:${counter}`);
80
+ try {
81
+ privateKeyFromRaw(candidate);
82
+ return candidate;
83
+ } catch {
84
+ log('derived scalar %d was not a valid secp256k1 key, retrying', counter);
85
+ }
86
+ }
87
+ // Unreachable short of a broken hash.
88
+ throw new Error('could not derive a valid secp256k1 key from the PRF seed');
89
+ }
90
+
91
+ /**
92
+ * Ask the authenticator for the credential's PRF output.
93
+ *
94
+ * Returns null whenever a stable PRF output cannot be obtained — no stored
95
+ * PRF input, no PRF support, or a refused assertion. Callers must treat null
96
+ * as "carry on without a derived key" rather than as an error.
97
+ *
98
+ * @param {Object} credential - Stored WebAuthn credential info.
99
+ * @param {Object} [options]
100
+ * @param {string} [options.rpId] - Relying party id.
101
+ * @returns {Promise<Uint8Array|null>} PRF output, or null.
102
+ */
103
+ export async function getPrfOutput(credential, { rpId } = {}) {
104
+ // Without the original input the output would differ per call, which is
105
+ // worse than not deriving at all.
106
+ if (!credential?.prfInput) {
107
+ log('credential has no stored prfInput; cannot derive a stable key');
108
+ return null;
109
+ }
110
+
111
+ if (typeof navigator === 'undefined' || !navigator.credentials?.get) {
112
+ log('no WebAuthn available; cannot derive a key');
113
+ return null;
114
+ }
115
+
116
+ const rawCredentialId =
117
+ credential.rawCredentialId instanceof Uint8Array
118
+ ? credential.rawCredentialId
119
+ : new Uint8Array(credential.rawCredentialId ?? []);
120
+
121
+ try {
122
+ const assertion = await navigator.credentials.get(
123
+ buildCredentialRequestOptions({
124
+ challenge: await crypto.subtle.digest(
125
+ CRYPTO_ALGORITHMS.SHA_256,
126
+ new TextEncoder().encode(DERIVATION_INFO)
127
+ ),
128
+ credentialId: rawCredentialId,
129
+ rpId: rpId ?? globalThis.window?.location?.hostname,
130
+ userVerification: 'required',
131
+ extensions: { prf: { eval: { first: credential.prfInput } } },
132
+ })
133
+ );
134
+
135
+ const results = assertion?.getClientExtensionResults?.()?.prf?.results;
136
+ if (!results?.first) {
137
+ log('authenticator returned no PRF output');
138
+ return null;
139
+ }
140
+ return new Uint8Array(results.first);
141
+ } catch (error) {
142
+ log('PRF assertion failed: %s', error.message);
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Seed the keystore with a PRF-derived signing key for this DID.
149
+ *
150
+ * An existing key is never replaced. Overwriting one would change `publicKey`
151
+ * and `signatures.id`, producing a second identity document for the same DID —
152
+ * precisely what this is meant to avoid. Installs that already hold a random
153
+ * key therefore keep it and stay on 0.4.0 behaviour, which is stable per
154
+ * device; only fresh installs get a reproducible identity.
155
+ *
156
+ * @param {Object} params
157
+ * @param {Object} params.keystore - OrbitDB keystore.
158
+ * @param {string} params.did - Identity DID.
159
+ * @param {Object} params.credential - Stored WebAuthn credential info.
160
+ * @param {string} [params.rpId] - Relying party id.
161
+ * @returns {Promise<'derived'|'existing'|'unavailable'>} What happened.
162
+ */
163
+ export async function ensureDerivedSigningKey({
164
+ keystore,
165
+ did,
166
+ credential,
167
+ rpId,
168
+ }) {
169
+ if (!keystore || !did || !credential) return 'unavailable';
170
+
171
+ try {
172
+ if (await keystore.getKey(did)) {
173
+ log('keystore already holds a key for this DID; leaving it alone');
174
+ return 'existing';
175
+ }
176
+ } catch (error) {
177
+ log('could not read the keystore: %s', error.message);
178
+ return 'unavailable';
179
+ }
180
+
181
+ const seed = await getPrfOutput(credential, { rpId });
182
+ if (!seed) {
183
+ log(
184
+ 'no PRF output; falling back to a keystore-generated key, so this identity is stable per device but not reproducible across devices'
185
+ );
186
+ return 'unavailable';
187
+ }
188
+
189
+ try {
190
+ const privateKey = await deriveSigningKeyBytes(seed, did);
191
+ await keystore.addKey(did, { privateKey });
192
+ log('seeded the keystore with a PRF-derived signing key');
193
+ return 'derived';
194
+ } catch (error) {
195
+ log('failed to seed the derived key: %s', error.message);
196
+ return 'unavailable';
197
+ }
198
+ }
@@ -7,6 +7,7 @@ import { generateKeyPair } from '@libp2p/crypto/keys';
7
7
  import { varint } from 'multiformats';
8
8
  import { base58btc } from 'multiformats/bases/base58';
9
9
  import * as KeystoreEncryption from './encryption.js';
10
+ import { ensureDerivedSigningKey } from './derived-signing-key.js';
10
11
  import { WebAuthnDIDProvider } from '../webauthn/provider.js';
11
12
  import {
12
13
  identityProofKey,
@@ -56,8 +57,12 @@ export class OrbitDBWebAuthnIdentityProvider {
56
57
  keystoreKeyType = KEY_TYPES.SECP256K1,
57
58
  encryptKeystore = false,
58
59
  keystoreEncryptionMethod = KEYSTORE_ENCRYPTION_METHODS.PRF,
60
+ deriveSigningKeyFromPrf = true,
59
61
  }) {
60
62
  this.credential = webauthnCredential;
63
+ // Costs one extra assertion the first time an identity is created on a
64
+ // device. Set false to keep a keystore-generated key instead.
65
+ this.deriveSigningKeyFromPrf = deriveSigningKeyFromPrf;
61
66
  this.webauthnProvider = new WebAuthnDIDProvider(webauthnCredential);
62
67
  this.type = IDENTITY_TYPES.WEBAUTHN; // Set instance property
63
68
  this.useKeystoreDID = useKeystoreDID; // Flag to use Ed25519 DID from keystore
@@ -76,7 +81,17 @@ export class OrbitDBWebAuthnIdentityProvider {
76
81
  * Resolve the identity DID.
77
82
  * @returns {Promise<string>} DID string.
78
83
  */
79
- async getId() {
84
+ /**
85
+ * Resolve the identity DID.
86
+ *
87
+ * OrbitDB passes its own keystore in `options` and calls this before
88
+ * `keystore.getKey(id)`, which is the only window in which the signing key
89
+ * can be seeded deterministically. See derived-signing-key.js.
90
+ *
91
+ * @param {Object} [options] - Options supplied by OrbitDB.
92
+ * @returns {Promise<string>} DID string.
93
+ */
94
+ async getId(options = {}) {
80
95
  identityLog('getId() called');
81
96
 
82
97
  // If useKeystoreDID flag is set, create Ed25519 DID from keystore
@@ -95,6 +110,22 @@ export class OrbitDBWebAuthnIdentityProvider {
95
110
 
96
111
  // Default: Return P-256 DID from WebAuthn credential
97
112
  const did = await WebAuthnDIDProvider.createDID(this.credential);
113
+
114
+ // Seed the signing key before OrbitDB reaches for it. Never fatal: without
115
+ // PRF the keystore generates its own key and the identity stays stable per
116
+ // device, just not reproducible across them.
117
+ if (this.deriveSigningKeyFromPrf !== false) {
118
+ const keystore = options.keystore ?? this.keystore;
119
+ if (keystore) {
120
+ const outcome = await ensureDerivedSigningKey({
121
+ keystore,
122
+ did,
123
+ credential: this.credential,
124
+ });
125
+ identityLog('Derived signing key: %s', outcome);
126
+ }
127
+ }
128
+
98
129
  identityLog(
99
130
  'getId() returning P-256 DID: %s',
100
131
  did.substring(0, 32) + '...'
@@ -199,6 +199,19 @@ export class WebAuthnDIDProvider {
199
199
  // Note: largeBlob write happens after credential creation
200
200
  }
201
201
 
202
+ // Request PRF even when the keystore is not encrypted. The stored input is
203
+ // what lets the OrbitDB signing key be derived reproducibly later, so a
204
+ // credential registered without it can never get a reproducible identity.
205
+ // Requesting the extension is harmless where it is unsupported: the
206
+ // authenticator simply returns no PRF results.
207
+ if (!prfInput) {
208
+ const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
209
+ credentialOptions.publicKey
210
+ );
211
+ credentialOptions.publicKey = prfConfig.credentialOptions;
212
+ prfInput = prfConfig.prfInput;
213
+ }
214
+
202
215
  try {
203
216
  const credential = await navigator.credentials.create(credentialOptions);
204
217