@le-space/orbitdb-identity-provider-webauthn-did 0.10.0 → 0.11.0

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
@@ -1,5 +1,24 @@
1
1
  # Changes
2
2
 
3
+ ## 0.11.0
4
+
5
+ Another session of the same passkey may still write: OrbitDB's verified-identity cache, worked
6
+ around in the provider so that no application carries a copy.
7
+
8
+ ### Added
9
+
10
+ - **`withoutStaleIdentityCache(identities)`** and **`createIdentities(options)`** (main entry and
11
+ `./keystore`): `@orbitdb/core` 4 caches a verified identity under `signatures.id` and, on a hit,
12
+ compares the whole identity. This provider keeps the id signature the same in every session of a
13
+ passkey (the PRF-derived signing key) while the WebAuthn proof in `signatures.publicKey` is new per
14
+ session — so whichever session's entries are verified first owns the cache, and every other
15
+ session of the same passkey is refused: _"Key … is not allowed to write to the log"_. Seen in
16
+ belege's restore (belege#259) and in simple-todo's archived01, a phone restoring a laptop's
17
+ archive (simple-todo#79). The wrapper re-checks a refused webauthn identity the way an uncached
18
+ one would be: `isIdentity`, the id signature over the public key, this provider's binding. Other
19
+ providers' identities and anything failing those checks stay refused. Upstream
20
+ orbitdb/orbitdb#1258; to be removed once OrbitDB keys its cache by identity hash (#90).
21
+
3
22
  ## 0.10.0
4
23
 
5
24
  An identity that is nobody's passkey, so that every security key of an owner can write as the same
package/README.md CHANGED
@@ -98,6 +98,30 @@ const identity = await identities.createIdentity({
98
98
  });
99
99
  ```
100
100
 
101
+ ### Identities for a passkey: `createIdentities`
102
+
103
+ OrbitDB keeps a cache of verified identities keyed by `signatures.id`. This provider keeps that
104
+ signature the same in every session of a passkey (the signing key is derived from the PRF output),
105
+ while the WebAuthn proof in `signatures.publicKey` is a fresh assertion per session. So whichever
106
+ session's entries a peer verifies first owns its cache, and every other session of the same passkey
107
+ is refused: _"Key … is not allowed to write to the log"_ — a phone restoring a laptop's backup, a
108
+ browser that gets its own earlier entries back from a relay ([orbitdb/orbitdb#1258](https://github.com/orbitdb/orbitdb/issues/1258),
109
+ [#90](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/issues/90)).
110
+
111
+ Until OrbitDB keys its cache by identity hash, build the identities with the provider's wrapper:
112
+
113
+ ```javascript
114
+ import { createIdentities } from '@le-space/orbitdb-identity-provider-webauthn-did';
115
+
116
+ const identities = await createIdentities({ ipfs, keystore }); // Identities(options), wrapped
117
+ const orbitdb = await createOrbitDB({ ipfs, identities, identity });
118
+ ```
119
+
120
+ or wrap an instance you already have with `withoutStaleIdentityCache(identities)`. Where OrbitDB
121
+ says no, a webauthn identity is checked again the way an uncached one would be — the shape, the id
122
+ signature over the public key, and this provider's binding of the key to the DID. Other providers'
123
+ identities, and anything that fails those checks, stay refused.
124
+
101
125
  ### Discoverable Recovery Notes
102
126
 
103
127
  For the DID-based flow:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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",
@@ -52,7 +52,8 @@
52
52
  "version": "git add -A",
53
53
  "postversion": "git push && git push --tags",
54
54
  "test:two-peer": "playwright test tests/webauthn-two-peer-replication.test.js --project=chromium --reporter=line",
55
- "test:node": "playwright test --config=playwright.node.config.js"
55
+ "test:node": "playwright test --config=playwright.node.config.js",
56
+ "test:wallet-fork": "PASSKEY_WALLET_FORK_URL=${PASSKEY_WALLET_FORK_URL:-https://sepolia.drpc.org} playwright test --config=playwright.fork.config.js"
56
57
  },
57
58
  "keywords": [
58
59
  "orbitdb",
package/src/index.js CHANGED
@@ -308,6 +308,12 @@ export {
308
308
  recoverPublicKeyCandidates,
309
309
  } from './webauthn/recovery.js';
310
310
  export { restoreIdentityFromAuthenticator } from './webauthn/restore.js';
311
+ // OrbitDB's identity cache refuses another session of the same passkey
312
+ // (orbitdb/orbitdb#1258); this is the provider's answer until it is fixed there.
313
+ export {
314
+ withoutStaleIdentityCache,
315
+ createIdentities,
316
+ } from './keystore/stale-identity-cache.js';
311
317
  // The two halves of it, for a caller that already holds one: a PRF output, or
312
318
  // a credential it wants the output for.
313
319
  export {
@@ -6,4 +6,8 @@ export { default } from './encryption.js';
6
6
  export * from './provider.js';
7
7
  export * from './session-keystore.js';
8
8
  export { createSecretSigner } from './secret-signer.js';
9
+ export {
10
+ withoutStaleIdentityCache,
11
+ createIdentities,
12
+ } from './stale-identity-cache.js';
9
13
  export { PrfUnavailableError } from '../errors.js';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * OrbitDB's verified-identity cache keyed by the wrong thing, worked around.
3
+ *
4
+ * `Identities.verifyIdentity` (@orbitdb/core 4) remembers a verified identity
5
+ * under `signatures.id` and, on a hit, answers `isEqual(identity, cached)`.
6
+ * With this provider the PRF-derived signing key gives the *same*
7
+ * `signatures.id` in every session of a passkey, while `signatures.publicKey`
8
+ * — the WebAuthn assertion that binds the key to the DID — is new per session.
9
+ * So once one session's identity is in the cache, every other session of the
10
+ * same passkey is refused: "Could not append entry: Key … is not allowed to
11
+ * write to the log". Whichever session's entries are verified first owns the
12
+ * cache: after a join (a restore from backup), after a sync (a peer that held
13
+ * the list), or simply the browser's own earlier entries coming back.
14
+ *
15
+ * Seen in Le-Space/belege's restore (belege#259) and in simple-todo's
16
+ * archived01 (a phone restoring a laptop's archive, Le-Space/simple-todo#79).
17
+ * Upstream: orbitdb/orbitdb#1258. The right fix is a cache keyed by identity
18
+ * hash; until OrbitDB has it, this is the provider's own answer, so that no
19
+ * application has to carry a copy.
20
+ *
21
+ * Where OrbitDB says no, an identity of this provider is checked again the way
22
+ * an uncached one would be: the shape (`isIdentity`), the id signature over
23
+ * the public key (`identities.verify`), and this provider's binding of the key
24
+ * to the DID (`verifyIdentity`, the check GHSA-326j-4cc3-4rrg added). Other
25
+ * providers' identities, and anything that fails those checks, stay refused.
26
+ */
27
+ import { OrbitDBWebAuthnIdentityProviderFunction } from './provider.js';
28
+
29
+ /**
30
+ * OrbitDB's own shape check (`identities/identity.js`, `isIdentity`), kept
31
+ * here rather than imported: this file is reachable from the `./keystore`
32
+ * entry, which does not load `@orbitdb/core` at import time — the peer
33
+ * dependency is the application's, and @orbitdb/core 4.0.0 cannot even be
34
+ * imported from a bare install (it uses `it-drain` without declaring it).
35
+ *
36
+ * @param {any} identity
37
+ */
38
+ function isIdentity(identity) {
39
+ return Boolean(
40
+ identity &&
41
+ identity.id &&
42
+ identity.hash &&
43
+ identity.bytes &&
44
+ identity.publicKey &&
45
+ identity.signatures &&
46
+ identity.signatures.id &&
47
+ identity.signatures.publicKey &&
48
+ identity.type
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Wrap an `Identities` instance so that another session of the same passkey
54
+ * is verified rather than refused. Idempotent in effect; wrapping twice only
55
+ * checks twice.
56
+ *
57
+ * @template {{ verifyIdentity: (identity: any) => Promise<boolean>, verify: (signature: string, publicKey: string, data: string) => Promise<boolean> }} T
58
+ * @param {T} identities - from `Identities(...)` (or `createIdentities` below)
59
+ * @returns {T} the same instance, its `verifyIdentity` replaced
60
+ */
61
+ export function withoutStaleIdentityCache(identities) {
62
+ if (
63
+ !identities ||
64
+ typeof identities.verifyIdentity !== 'function' ||
65
+ typeof identities.verify !== 'function'
66
+ ) {
67
+ throw new TypeError(
68
+ 'withoutStaleIdentityCache needs an OrbitDB Identities instance (verifyIdentity, verify)'
69
+ );
70
+ }
71
+ const cached = identities.verifyIdentity;
72
+ identities.verifyIdentity = async (identity) => {
73
+ if (await cached(identity)) return true;
74
+ if (
75
+ !isIdentity(identity) ||
76
+ identity.type !== OrbitDBWebAuthnIdentityProviderFunction.type
77
+ ) {
78
+ return false;
79
+ }
80
+ const { id, publicKey, signatures } = identity;
81
+ if (!(await identities.verify(signatures.id, publicKey, id))) return false;
82
+ return OrbitDBWebAuthnIdentityProviderFunction.verifyIdentity(identity);
83
+ };
84
+ return identities;
85
+ }
86
+
87
+ /**
88
+ * `Identities(options)` from @orbitdb/core, already wrapped: what an
89
+ * application that builds identities for a passkey should call. The peer
90
+ * dependency is loaded when this is called, not when the module is.
91
+ *
92
+ * @param {Record<string, unknown>} [options] - `{ ipfs, keystore, storage, path }`, as OrbitDB takes them
93
+ */
94
+ export async function createIdentities(options) {
95
+ const { Identities } = await import('@orbitdb/core');
96
+ return withoutStaleIdentityCache(await Identities(options));
97
+ }
package/types/index.d.ts CHANGED
@@ -659,3 +659,26 @@ declare const defaultExport: {
659
659
  };
660
660
 
661
661
  export default defaultExport;
662
+
663
+ /**
664
+ * OrbitDB's verified-identity cache is keyed by `signatures.id`, which this
665
+ * provider keeps the same in every session of a passkey while the WebAuthn
666
+ * proof changes; another session of the same passkey is then refused
667
+ * (orbitdb/orbitdb#1258). Wraps an `Identities` instance so that such an
668
+ * identity is checked again the way an uncached one would be.
669
+ */
670
+ export function withoutStaleIdentityCache<
671
+ T extends {
672
+ verifyIdentity: (identity: unknown) => Promise<boolean>;
673
+ verify: (
674
+ signature: string,
675
+ publicKey: string,
676
+ data: string
677
+ ) => Promise<boolean>;
678
+ },
679
+ >(identities: T): T;
680
+
681
+ /** `Identities(options)` from `@orbitdb/core`, already wrapped by `withoutStaleIdentityCache`. */
682
+ export function createIdentities(
683
+ options?: Record<string, unknown>
684
+ ): Promise<unknown>;
@@ -206,3 +206,26 @@ declare const defaultExport: {
206
206
  };
207
207
 
208
208
  export default defaultExport;
209
+
210
+ /**
211
+ * OrbitDB's verified-identity cache is keyed by `signatures.id`, which this
212
+ * provider keeps the same in every session of a passkey while the WebAuthn
213
+ * proof changes; another session of the same passkey is then refused
214
+ * (orbitdb/orbitdb#1258). Wraps an `Identities` instance so that such an
215
+ * identity is checked again the way an uncached one would be.
216
+ */
217
+ export function withoutStaleIdentityCache<
218
+ T extends {
219
+ verifyIdentity: (identity: unknown) => Promise<boolean>;
220
+ verify: (
221
+ signature: string,
222
+ publicKey: string,
223
+ data: string
224
+ ) => Promise<boolean>;
225
+ },
226
+ >(identities: T): T;
227
+
228
+ /** `Identities(options)` from `@orbitdb/core`, already wrapped by `withoutStaleIdentityCache`. */
229
+ export function createIdentities(
230
+ options?: Record<string, unknown>
231
+ ): Promise<unknown>;