@le-space/orbitdb-identity-provider-webauthn-did 0.5.3 → 0.6.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 +208 -1
- package/README.md +123 -51
- package/SECURITY.md +53 -16
- package/docs/API.md +1 -1
- package/package.json +1 -1
- package/src/errors.js +14 -0
- package/src/index.js +24 -0
- package/src/keystore/derived-signing-key.js +9 -7
- package/src/keystore/encryption.js +44 -19
- package/src/keystore/index.js +2 -0
- package/src/keystore/provider.js +88 -4
- package/src/keystore/session-keystore.js +79 -0
- package/src/standalone/index.js +1 -0
- package/src/standalone/webauthn/credential.js +29 -27
- package/src/standalone/worker/client.js +38 -0
- package/src/standalone/worker/ed25519-keystore.worker.js +65 -6
- package/src/varsig/assertion.js +8 -21
- package/src/varsig/credential.js +6 -0
- package/src/varsig/identity.js +9 -3
- package/src/webauthn/large-blob-metadata.js +9 -1
- package/src/webauthn/prf-input.js +46 -0
- package/src/webauthn/proof-verification.js +7 -2
- package/src/webauthn/provider.js +33 -36
- package/src/webauthn/recovery.js +242 -0
- package/src/webauthn/restore.js +121 -0
- package/types/index.d.ts +46 -0
- package/types/keystore.d.ts +31 -0
- package/types/standalone.d.ts +24 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,213 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
An identity can be restored on a second device with **nothing stored**: two
|
|
6
|
+
touches of the same passkey give back the DID and the signing key, so
|
|
7
|
+
largeBlob and `localStorage` become a convenience rather than the only road.
|
|
8
|
+
Measured on two phones with one YubiKey before it was written (#61).
|
|
9
|
+
|
|
10
|
+
Nothing existing moves: a credential registered before this keeps the PRF
|
|
11
|
+
input it was made with, and the identity it derives is unchanged. Credentials
|
|
12
|
+
registered from here on are asked the input every device can compute, which is
|
|
13
|
+
what makes the restore work. Two behaviours do change — `getPrfOutput()` now
|
|
14
|
+
falls back to that derivable input instead of returning `null` when a
|
|
15
|
+
credential has none stored, and `createDID()` throws instead of inventing a
|
|
16
|
+
"base58-like" identifier when it cannot encode a key.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **An identity can be restored on a device that has stored nothing** (#61).
|
|
21
|
+
`restoreIdentityFromAuthenticator()` touches the same passkey twice: once
|
|
22
|
+
with user verification and the PRF input every device can compute, once for a
|
|
23
|
+
second signature. Two ECDSA signatures admit exactly one common public key —
|
|
24
|
+
which an assertion does not carry, and which is why the identity metadata has
|
|
25
|
+
had to live in largeBlob or `localStorage` until now. From it: the DID, and
|
|
26
|
+
the signing key derived from the PRF output with the DID mixed in. The pieces
|
|
27
|
+
are exported on their own as well: `prfInputForRelyingParty()`,
|
|
28
|
+
`recoverPublicKey()` and `recoverPublicKeyCandidates()`.
|
|
29
|
+
|
|
30
|
+
Measured on hardware before it was written — one YubiKey, a Galaxy Fold 5 and
|
|
31
|
+
a Galaxy A57, 2026-09-19: the same PRF value on both phones, the same DID
|
|
32
|
+
recovered from two signatures, the same derived signing key. What of that is
|
|
33
|
+
machine-checkable runs in `tests/webauthn-recovery.test.js`, against a
|
|
34
|
+
software authenticator with a real P-256 key.
|
|
35
|
+
|
|
36
|
+
Nothing stands in for a missing PRF: an authenticator without it gets a
|
|
37
|
+
refusal, because an identity derived from something else is a different
|
|
38
|
+
identity that looks like success.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **The PRF input is now fixed per relying party** instead of drawn at random
|
|
43
|
+
per credential (#61). It was stored with the credential metadata, so the
|
|
44
|
+
derived signing key — and with it the identity — was reachable only from the
|
|
45
|
+
device that kept that metadata; a second device asking the same authenticator
|
|
46
|
+
a different question got a different answer. The input is not a secret, the
|
|
47
|
+
output is. Credentials registered earlier keep working: their stored input is
|
|
48
|
+
still used when there is one, and `getPrfOutput()` now falls back to the
|
|
49
|
+
derivable input instead of giving up when there is none.
|
|
50
|
+
- **`createDID` refuses a key it cannot encode.** Its `catch` built a
|
|
51
|
+
"base58-like" identifier out of the same coordinates: a string that looked
|
|
52
|
+
like a DID, was not `did:key`, and could not be reproduced by anything else
|
|
53
|
+
reading that key — including this library on another device. It throws now.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- CI now runs all 22 test files. Eight never ran there: `identity-forgery`,
|
|
58
|
+
`p256-signature-shapes`, `session-keystore`, `webauthn-debug-log`,
|
|
59
|
+
`webauthn-extension-support`, `webauthn-user-handle`,
|
|
60
|
+
`webauthn-varsig-challenge` and `worker-signer`. They were listed in
|
|
61
|
+
`playwright.node.config.js`, which only the local `preversion` and
|
|
62
|
+
`prepublishOnly` hooks ran, while `release.yml` publishes with
|
|
63
|
+
`--ignore-scripts` because CI is its gate — so no automated gate ran them
|
|
64
|
+
before a release. CI now runs that config in one step, before installing
|
|
65
|
+
browsers; `standalone-toolkit`, `webauthn-attestation-parsing` and
|
|
66
|
+
`webauthn-two-peer-replication` lose their separate steps, which booted a
|
|
67
|
+
demo server they do not use.
|
|
68
|
+
- `identity-forgery` did not load in a clean install. It imported `concat`
|
|
69
|
+
from `uint8arrays`, which this package does not declare; only a hoisted
|
|
70
|
+
`node_modules` (`node-linker=hoisted`) made it resolvable. It now takes
|
|
71
|
+
`concat` from `iso-base/utils`, a declared dependency.
|
|
72
|
+
- `scripts/check-ci-runs-every-test.mjs` fails CI when a file in `tests/` is
|
|
73
|
+
selected by no step. This is the second time suites went unrun: 0.4.0 found
|
|
74
|
+
five.
|
|
75
|
+
|
|
76
|
+
## 0.5.4
|
|
77
|
+
|
|
78
|
+
Fixes a verification bug that refused about one P-256 passkey identity in
|
|
79
|
+
128 at every peer, since 0.5.2 (see _Fixed_). Upgrade if you use P-256
|
|
80
|
+
passkeys, which is most platform authenticators.
|
|
81
|
+
|
|
82
|
+
**Upgrade note.** `encryptKeystore` no longer falls back to the credential
|
|
83
|
+
id when the authenticator has no PRF: `wrapSKWithPRF`/`unwrapSKWithPRF`
|
|
84
|
+
throw `PrfUnavailableError`, and `extractPrfSeedFromCredential` returns
|
|
85
|
+
`{ seed: null, source: 'none' }`. The credential id sits in localStorage in
|
|
86
|
+
clear, so a key sealed that way was never sealed. An app that used
|
|
87
|
+
`encryptKeystore` with users whose authenticator has no PRF cannot unseal
|
|
88
|
+
their old key; those users get a fresh keystore DID. The default path and
|
|
89
|
+
varsig are unaffected.
|
|
90
|
+
|
|
91
|
+
### Added
|
|
92
|
+
|
|
93
|
+
- `signer` option and `createSessionKeystore({ signer })`: a key that signs
|
|
94
|
+
elsewhere — typically a Web Worker — becomes the identity. The keystore
|
|
95
|
+
answers for the signer's DID with a key object whose `sign()` calls the
|
|
96
|
+
signer, OrbitDB signs through it, and no private key exists in the page.
|
|
97
|
+
From the standalone toolkit, `client.deriveSigner(prfSeed)` derives an
|
|
98
|
+
Ed25519 key from the passkey's PRF output inside the worker and returns
|
|
99
|
+
only its DID and public key; `createWorkerSigner(client, { did, publicKey })`
|
|
100
|
+
turns that into the signer.
|
|
101
|
+
- `createSessionKeystore()`: an OrbitDB keystore on memory storage. OrbitDB
|
|
102
|
+
signs with whatever its keystore returns, and the default keystore writes
|
|
103
|
+
every key to disk — so with `encryptKeystore` the unlocked key was sitting
|
|
104
|
+
in IndexedDB unencrypted, and the sealed copy protected nothing after the
|
|
105
|
+
first session. Hand this keystore to `Identities()` and the unlocked key
|
|
106
|
+
lives in memory only; the provider warns when it is given any other.
|
|
107
|
+
- `provider.encryptionState`: `{ enabled, method | reason }`, so an app can
|
|
108
|
+
show whether the key is actually sealed.
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
|
|
112
|
+
- **No PRF, no seal.** When the authenticator returns no PRF output,
|
|
113
|
+
`wrapSKWithPRF`/`unwrapSKWithPRF` throw `PrfUnavailableError` and
|
|
114
|
+
`extractPrfSeedFromCredential` returns `{ seed: null, source: 'none' }`.
|
|
115
|
+
They used to fall back to the raw credential id, which sits in
|
|
116
|
+
localStorage in clear, so a key "wrapped with PRF" could be wrapped with
|
|
117
|
+
nothing secret. The provider carries on without encryption in that case
|
|
118
|
+
(`encryptionState.reason === 'prf-unavailable'`) and warns. Anything that
|
|
119
|
+
relied on the `'credentialId'` source has to decide for itself now.
|
|
120
|
+
- A varsig identity's id is always derived from its public key.
|
|
121
|
+
`createWebAuthnVarsigIdentity` used to let a stored `credential.did` win,
|
|
122
|
+
so metadata recovered from largeBlob or localStorage — data anyone with
|
|
123
|
+
access to either can edit — decided which DID an identity claimed.
|
|
124
|
+
Verification would then refuse it (0.5.2+), but an app showed the wrong
|
|
125
|
+
DID first.
|
|
126
|
+
- Both `createCredential` paths register with
|
|
127
|
+
`largeBlob: { support: 'preferred' }`. Identity metadata is written to
|
|
128
|
+
largeBlob after registration, and an authenticator only allows that for a
|
|
129
|
+
credential created asking for it; without this the write was refused and
|
|
130
|
+
recovery through "Use Existing Passkey" found nothing once localStorage
|
|
131
|
+
was gone. A `required` set by the largeBlob encryption method stays.
|
|
132
|
+
- The largeBlob identity payload (`createDidLargeBlobPayload`, version 2)
|
|
133
|
+
carries the credential's `prfInput`. Without it a recovered credential
|
|
134
|
+
could name its DID but not re-derive its signing key, so a second device
|
|
135
|
+
got a key of its own — the one thing the derived key exists to prevent.
|
|
136
|
+
Version 1 payloads still parse.
|
|
137
|
+
- `clearIdentityProofs` is exported from the package root, so an app can
|
|
138
|
+
forget an identity completely: credential metadata, stored identity
|
|
139
|
+
assertion, keystore.
|
|
140
|
+
|
|
141
|
+
### Fixed
|
|
142
|
+
|
|
143
|
+
- **Genuine P-256 signatures were refused about once in 90.** An
|
|
144
|
+
authenticator returns an ES256 signature as DER with minimal integers, so r
|
|
145
|
+
or s can be 31 bytes long. The default path's identity binding unwrapped
|
|
146
|
+
it with a helper that did not pad them back to 32, so about one passkey
|
|
147
|
+
identity in 128 failed verification — at every peer, and permanently,
|
|
148
|
+
because the proof is stored and reused (since 0.5.2). The varsig path had
|
|
149
|
+
the same unwrap plus a second fault: the raw r‖s went to a verifier that
|
|
150
|
+
takes DER, and one that started with 0x30 was parsed as DER and refused.
|
|
151
|
+
Both now leave the conversion to `derToRawSignature`/`verifyP256Signature`
|
|
152
|
+
from `iso-webauthn-varsig`. Found as a 2% flake in the forgery suite;
|
|
153
|
+
`tests/p256-signature-shapes.test.js` forces each shape through the mock
|
|
154
|
+
authenticator's new `signatureShape` option.
|
|
155
|
+
- The `/keystore` subpath exports `PrfUnavailableError`, which its type
|
|
156
|
+
declarations already promised.
|
|
157
|
+
- Creating an encrypted keystore no longer unlocks it straight afterwards:
|
|
158
|
+
the pair is already in memory, and the second prompt bought nothing.
|
|
159
|
+
- The static `OrbitDBWebAuthnIdentityProvider.createIdentity` created a new
|
|
160
|
+
encrypted keystore on every call, replacing the sealed key; it now loads
|
|
161
|
+
the existing one and creates only when there is none.
|
|
162
|
+
|
|
163
|
+
### Examples
|
|
164
|
+
|
|
165
|
+
- One module per option under `examples/shared/lib/options/`
|
|
166
|
+
(`default-path.js`, `encrypted-keystore.js`, `varsig.js`) builds the
|
|
167
|
+
identity for that option; the demos import them, and each is covered by
|
|
168
|
+
the suite that drives its demo. The four scripted examples in
|
|
169
|
+
`examples/*.js` are gone — none of them ran (Node globals in the browser,
|
|
170
|
+
browser globals in Node, an import that did not exist), and no test ever
|
|
171
|
+
executed them.
|
|
172
|
+
- The three demos share one stack (`examples/shared/lib/stack.js`) and one
|
|
173
|
+
todo store. Each demo prefixes its IndexedDB stores with its own name, so
|
|
174
|
+
"Reset DB" in one demo no longer wipes the other two on the shared
|
|
175
|
+
GitHub Pages origin; the varsig demo's reset now waits for the deletes and
|
|
176
|
+
closes the Level stores first, like the others already did. No more HTTP
|
|
177
|
+
gateway fallback in the demos.
|
|
178
|
+
- `docs/EXAMPLE-SEQUENCES.md` rewritten around the three options, with the
|
|
179
|
+
prompt counts the suites assert; `docs/WEBAUTHN-ENCRYPTED-KEYSTORE-INTEGRATION.md`
|
|
180
|
+
no longer promises what `encryptKeystore` cannot do on the default keystore,
|
|
181
|
+
and no longer names a `secretKey` option that does not exist.
|
|
182
|
+
- The encrypted-keystore demo gives OrbitDB a session keystore in both of its
|
|
183
|
+
modes, so its "encrypted at rest" is true, and its worker mode is now the
|
|
184
|
+
real signer: the key is derived from the passkey inside the worker and
|
|
185
|
+
OrbitDB signs through it (`createWorkerSigner`, `signer` option). The
|
|
186
|
+
archive the worker used to hand to the page, the probe panel beside the
|
|
187
|
+
real signing, and the credential-id fallback are gone; the panel shows the
|
|
188
|
+
signing backend, what is at rest and the prompt count.
|
|
189
|
+
- The default demo lets you pick the derived key's type (`signingKeyType`),
|
|
190
|
+
shows the identity document's hash, the key in use and the number of
|
|
191
|
+
WebAuthn prompts, and splits "Logout" (keeps the passkey metadata) from
|
|
192
|
+
"Forget identity" (removes everything this device holds). Its own
|
|
193
|
+
credential store dropped `prfInput`; it uses the library's now.
|
|
194
|
+
- One pnpm workspace for the three demos: one lockfile, one set of
|
|
195
|
+
dependencies, `examples/shared` for what they share.
|
|
196
|
+
- The "Verified" badge is now the verdict of a real check — entry signature,
|
|
197
|
+
identity block, DID binding, write list — and each demo carries a panel
|
|
198
|
+
that runs the same verifier on two forgeries. The old badge compared the
|
|
199
|
+
database's identity with itself.
|
|
200
|
+
- The varsig demo no longer honours `window.__PLAYWRIGHT__`; its browser
|
|
201
|
+
test drives the real flow with a virtual authenticator, including
|
|
202
|
+
recovery from largeBlob.
|
|
203
|
+
- Demo READMEs, the README's option table and SECURITY.md describe what the
|
|
204
|
+
code does: which key signs, what sits at rest, how many prompts.
|
|
205
|
+
|
|
206
|
+
### Tests
|
|
207
|
+
|
|
208
|
+
- The mock authenticator returns DER-encoded P-256 signatures, as
|
|
209
|
+
authenticators do. Raw r‖s starting with 0x30 (one in 256) was read as
|
|
210
|
+
DER by the varsig decoder and refused.
|
|
4
211
|
|
|
5
212
|
## 0.5.3
|
|
6
213
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ This package provides:
|
|
|
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
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.
|
|
14
|
-
- **Keystore-based DID**:
|
|
14
|
+
- **Keystore-based DID**: an Ed25519/secp256k1 key pair in the OrbitDB keystore is the identity and signs entries. With `encryptKeystore` a sealed copy of that key sits in localStorage and the passkey unlocks it once per session (PRF; largeBlob or hmac-secret where supported). The unlocked key then lives in OrbitDB's own keystore — give OrbitDB `createSessionKeystore()` so that copy stays in memory; with the default keystore it is written to IndexedDB unencrypted, and the provider warns. Without PRF nothing is sealed: the key goes in unencrypted and `provider.encryptionState` says so.
|
|
15
15
|
|
|
16
16
|
## Current WebAuthn Model
|
|
17
17
|
|
|
@@ -28,10 +28,20 @@ In this repo today, metadata recovery works in two layers:
|
|
|
28
28
|
|
|
29
29
|
This means discoverable credentials remove the need to pre-select the credential for authentication, but they do not by themselves eliminate the need for identity metadata persistence.
|
|
30
30
|
|
|
31
|
-
**
|
|
31
|
+
**Which option, and what each one actually gives you:**
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
| | Default (passkey DID + derived key) | Keystore DID + `encryptKeystore` | Varsig |
|
|
34
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
|
35
|
+
| DID | the passkey's P-256 key | the keystore key (Ed25519/secp256k1) | the passkey's key |
|
|
36
|
+
| Signs an entry | a key derived from the passkey's PRF output (secp256k1, or Ed25519 with `signingKeyType`) | the keystore key, sealed by the passkey and unlocked once per session | the passkey, every write |
|
|
37
|
+
| Prompts per write | 0 | 0 | 1 |
|
|
38
|
+
| Prompts per session | 0 after the first (the identity assertion is stored) | 1 (unlock) | 2 (identity assertions) |
|
|
39
|
+
| Same identity on a second device | yes, given PRF | no — a new keystore key, unless the sealed copy travels | yes |
|
|
40
|
+
| At rest in the browser | the derived key, **unencrypted**, in OrbitDB's keystore | the sealed copy only, **if** OrbitDB gets `createSessionKeystore()`; otherwise the unlocked key too | nothing |
|
|
41
|
+
| Without PRF | a generated key, one device only | not sealed at all (`encryptionState.reason === 'prf-unavailable'`) | unaffected |
|
|
42
|
+
| What a peer verifies | the WebAuthn assertion binding the DID's key to the derived key, then each entry's signature | the DID is the signing key, then each entry's signature | each entry's varsig against the DID's key |
|
|
43
|
+
|
|
44
|
+
Varsig is the only option with no signing key in JavaScript at all; it costs a prompt per write and binds entries to the origin. The default is the cheapest and moves with the passkey, but leaves a software key on disk. The sealed keystore sits between, and only means something at rest with a session keystore.
|
|
35
45
|
|
|
36
46
|
Note: WebAuthn varsig support in this repo relies on our forked `@le-space/iso-*` packages of [Hugo Dias iso-repo](https://github.com/hugomrdias/iso-repo/) (notably `@le-space/iso-did` and `@le-space/iso-webauthn-varsig`) to align with the updated varsig flow.
|
|
37
47
|
|
|
@@ -178,50 +188,102 @@ sequenceDiagram
|
|
|
178
188
|
participant App
|
|
179
189
|
participant WebAuthn
|
|
180
190
|
participant Auth as Authenticator
|
|
181
|
-
participant
|
|
182
|
-
participant
|
|
191
|
+
participant Prov as Keystore-DID provider
|
|
192
|
+
participant LS as localStorage
|
|
193
|
+
participant KS as OrbitDB keystore
|
|
183
194
|
participant DB as OrbitDB
|
|
184
195
|
|
|
185
196
|
User->>App: Create credential
|
|
186
|
-
App->>WebAuthn: create()
|
|
197
|
+
App->>WebAuthn: create() with PRF and largeBlob requested
|
|
187
198
|
WebAuthn->>Auth: Create passkey
|
|
188
199
|
Auth-->>WebAuthn: Attestation
|
|
189
200
|
WebAuthn-->>App: Credential
|
|
190
201
|
|
|
191
|
-
App->>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
alt prf
|
|
199
|
-
App->>WebAuthn: get() with PRF
|
|
202
|
+
App->>Prov: createIdentity({ useKeystoreDID, encryptKeystore })
|
|
203
|
+
alt encryptKeystore, first session
|
|
204
|
+
Prov->>Prov: generateSecretKey() gives sk
|
|
205
|
+
Prov->>Prov: generateKeyPair() gives a random Ed25519 key
|
|
206
|
+
Prov->>Prov: AES-GCM encrypt the private key with sk
|
|
207
|
+
alt prf (the default)
|
|
208
|
+
Prov->>WebAuthn: get() with PRF
|
|
200
209
|
WebAuthn->>Auth: User verification
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
alt PRF output
|
|
211
|
+
Auth-->>Prov: PRF output
|
|
212
|
+
Prov->>Prov: wrap sk with a key derived from it
|
|
213
|
+
Prov->>LS: sealed key, wrapped sk, PRF input
|
|
214
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
215
|
+
else no PRF
|
|
216
|
+
Auth-->>Prov: no PRF result
|
|
217
|
+
Note right of Prov: Nothing is sealed. encryptionState is { enabled false, reason prf-unavailable }
|
|
218
|
+
Prov->>KS: getKey(), or add a generated key
|
|
219
|
+
end
|
|
204
220
|
else largeBlob
|
|
205
|
-
|
|
221
|
+
Prov->>WebAuthn: get() with largeBlob write
|
|
206
222
|
WebAuthn->>Auth: User verification
|
|
207
|
-
|
|
208
|
-
|
|
223
|
+
alt written
|
|
224
|
+
Auth-->>Prov: written is true
|
|
225
|
+
Prov->>LS: sealed key, sk kept on the authenticator
|
|
226
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
227
|
+
else not written
|
|
228
|
+
Auth-->>Prov: written is false
|
|
229
|
+
Note right of Prov: Throws. Nothing is stored.
|
|
230
|
+
end
|
|
209
231
|
else hmac-secret
|
|
210
|
-
|
|
232
|
+
Prov->>WebAuthn: get() with hmac-secret
|
|
211
233
|
WebAuthn->>Auth: User verification
|
|
212
|
-
Auth-->>
|
|
213
|
-
|
|
214
|
-
|
|
234
|
+
Auth-->>Prov: HMAC output
|
|
235
|
+
Prov->>Prov: wrap sk with it
|
|
236
|
+
Prov->>LS: sealed key, wrapped sk
|
|
237
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
215
238
|
end
|
|
239
|
+
else encryptKeystore with prf, later session
|
|
240
|
+
Prov->>LS: load the sealed key
|
|
241
|
+
Prov->>WebAuthn: get() with PRF, the stored input
|
|
242
|
+
Auth-->>Prov: PRF output
|
|
243
|
+
Prov->>Prov: unwrap sk, then decrypt the key
|
|
244
|
+
Prov->>KS: addKey(did, the unlocked key)
|
|
245
|
+
else not encrypted
|
|
246
|
+
Prov->>KS: getKey(), or add a generated key
|
|
216
247
|
end
|
|
248
|
+
Prov-->>App: Identity, DID = did:key of the keystore key
|
|
217
249
|
|
|
250
|
+
User->>App: Add entry
|
|
218
251
|
App->>DB: db.put()
|
|
219
|
-
DB->>KS: sign entry with keystore key
|
|
252
|
+
DB->>KS: sign entry with the keystore key
|
|
220
253
|
KS-->>DB: Entry signature
|
|
221
254
|
|
|
222
|
-
Note over App,KS:
|
|
255
|
+
Note over App,KS: The sealed copy stays at rest. The unlocked key lives in the OrbitDB keystore, so create it with createSessionKeystore() to keep it in memory.
|
|
223
256
|
```
|
|
224
257
|
|
|
258
|
+
OrbitDB signs with whatever its keystore returns, and the default keystore
|
|
259
|
+
persists every key it holds. So `encryptKeystore` needs a keystore that
|
|
260
|
+
forgets:
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { Identities } from '@orbitdb/core';
|
|
264
|
+
import {
|
|
265
|
+
OrbitDBWebAuthnIdentityProviderFunction,
|
|
266
|
+
createSessionKeystore,
|
|
267
|
+
} from '@le-space/orbitdb-identity-provider-webauthn-did';
|
|
268
|
+
|
|
269
|
+
const keystore = await createSessionKeystore(); // memory only
|
|
270
|
+
const identities = await Identities({ ipfs, keystore });
|
|
271
|
+
const identity = await identities.createIdentity({
|
|
272
|
+
provider: OrbitDBWebAuthnIdentityProviderFunction({
|
|
273
|
+
webauthnCredential,
|
|
274
|
+
useKeystoreDID: true,
|
|
275
|
+
keystoreKeyType: 'Ed25519',
|
|
276
|
+
encryptKeystore: true,
|
|
277
|
+
keystore,
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Every session unlocks the sealed copy again (one passkey prompt). Without
|
|
283
|
+
PRF the provider does not seal anything — it used to fall back to the
|
|
284
|
+
credential id, which is not a secret — and reports
|
|
285
|
+
`encryptionState: { enabled: false, reason: 'prf-unavailable' }`.
|
|
286
|
+
|
|
225
287
|
### Varsig (no keystore)
|
|
226
288
|
|
|
227
289
|
```mermaid
|
|
@@ -235,24 +297,28 @@ sequenceDiagram
|
|
|
235
297
|
participant DB as OrbitDB
|
|
236
298
|
|
|
237
299
|
User->>App: Create credential
|
|
238
|
-
App->>WebAuthn: create()
|
|
300
|
+
App->>WebAuthn: create() with largeBlob requested
|
|
239
301
|
WebAuthn->>Auth: Create passkey
|
|
240
302
|
Auth-->>WebAuthn: Attestation
|
|
241
303
|
WebAuthn-->>App: Credential
|
|
242
304
|
|
|
243
305
|
User->>App: Create varsig identity
|
|
244
|
-
App->>Var:
|
|
245
|
-
Var->>
|
|
306
|
+
App->>Var: createWebAuthnVarsigIdentity({ credential })
|
|
307
|
+
Var->>Var: id = did:key of the passkey's public key
|
|
308
|
+
Var->>WebAuthn: get() over the id
|
|
246
309
|
WebAuthn->>Auth: User verification
|
|
247
310
|
Auth-->>WebAuthn: Assertion
|
|
248
|
-
WebAuthn-->>Var: Assertion
|
|
249
|
-
Var->>
|
|
311
|
+
WebAuthn-->>Var: Assertion, becomes signatures.id
|
|
312
|
+
Var->>WebAuthn: get() over publicKey + idSignature
|
|
313
|
+
WebAuthn->>Auth: User verification
|
|
314
|
+
Auth-->>WebAuthn: Assertion
|
|
315
|
+
WebAuthn-->>Var: Assertion, becomes signatures.publicKey
|
|
250
316
|
Var-->>App: Identity
|
|
251
317
|
|
|
252
318
|
User->>App: Add entry
|
|
253
319
|
App->>DB: db.put()
|
|
254
|
-
DB->>Var:
|
|
255
|
-
Var->>WebAuthn: get()
|
|
320
|
+
DB->>Var: sign(entry)
|
|
321
|
+
Var->>WebAuthn: get() over the entry
|
|
256
322
|
WebAuthn->>Auth: User verification
|
|
257
323
|
Auth-->>WebAuthn: Assertion
|
|
258
324
|
WebAuthn-->>Var: Assertion
|
|
@@ -262,22 +328,28 @@ sequenceDiagram
|
|
|
262
328
|
|
|
263
329
|
## Examples
|
|
264
330
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
- `examples/ed25519-keystore-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
331
|
+
Three demo apps, one per option, in one pnpm workspace (`examples/README.md`
|
|
332
|
+
has the install). Each proves its option in the browser: a real
|
|
333
|
+
authenticator (Chromium's virtual one in CI), a badge that is the verdict of
|
|
334
|
+
the same checks a peer makes, and a panel that runs those checks on two
|
|
335
|
+
forgeries.
|
|
336
|
+
|
|
337
|
+
- `examples/webauthn-todo-demo/` — the default: passkey DID, entries signed by
|
|
338
|
+
a key derived from the passkey (secp256k1 or Ed25519), no prompt per write.
|
|
339
|
+
Live: https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-todo-demo/
|
|
340
|
+
- `examples/ed25519-encrypted-keystore-demo/` — an Ed25519 keystore DID,
|
|
341
|
+
either sealed by the passkey and unlocked into a session keystore, or
|
|
342
|
+
signed in a Web Worker that derives the key from the passkey and never
|
|
343
|
+
hands it to the page. Live:
|
|
344
|
+
https://le-space.github.io/orbitdb-identity-provider-webauthn-did/ed25519-encrypted-keystore-demo/
|
|
345
|
+
- `examples/webauthn-varsig-demo/` — varsig: the passkey signs every write,
|
|
346
|
+
no keystore. Live:
|
|
347
|
+
https://le-space.github.io/orbitdb-identity-provider-webauthn-did/webauthn-varsig-demo/
|
|
348
|
+
|
|
349
|
+
The identity for each option is built by one module under
|
|
350
|
+
`examples/shared/lib/options/`, which is also the shortest way to read the
|
|
351
|
+
API; `docs/EXAMPLE-SEQUENCES.md` walks through the three sequences and their
|
|
352
|
+
prompt counts.
|
|
281
353
|
|
|
282
354
|
## Documentation
|
|
283
355
|
|
package/SECURITY.md
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported Versions
|
|
4
4
|
|
|
5
|
-
Security fixes are provided for the current `0.
|
|
5
|
+
Security fixes are provided for the current `0.5.x` release line.
|
|
6
6
|
|
|
7
7
|
| Version | Supported |
|
|
8
8
|
| ------- | --------- |
|
|
9
|
-
| 0.
|
|
10
|
-
| < 0.
|
|
9
|
+
| 0.5.x | Yes |
|
|
10
|
+
| < 0.5 | No |
|
|
11
|
+
|
|
12
|
+
Every release up to and including 0.5.2 is affected by
|
|
13
|
+
[GHSA-326j-4cc3-4rrg](https://github.com/Le-Space/orbitdb-identity-provider-webauthn-did/security/advisories/GHSA-326j-4cc3-4rrg)
|
|
14
|
+
(identity verification checked shapes, not signatures). Use 0.5.3 or later.
|
|
11
15
|
|
|
12
16
|
## Reporting a Vulnerability
|
|
13
17
|
|
|
@@ -35,22 +39,49 @@ This package provides WebAuthn-based identity providers and helper APIs for
|
|
|
35
39
|
OrbitDB. It handles sensitive identity material and should be reviewed in the
|
|
36
40
|
context of the application that embeds it.
|
|
37
41
|
|
|
42
|
+
There are three ways to use it, and they protect different things. The
|
|
43
|
+
README carries a table; the short version:
|
|
44
|
+
|
|
38
45
|
### Varsig Provider
|
|
39
46
|
|
|
40
|
-
The
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
The only option with no signing key in JavaScript: every entry is a WebAuthn
|
|
48
|
+
assertion in a varsig envelope, so the private key never leaves the
|
|
49
|
+
authenticator (or the passkey provider — synced passkeys are not
|
|
50
|
+
device-bound). It costs one prompt per write, and entries are bound to the
|
|
51
|
+
origin they were signed on.
|
|
52
|
+
|
|
53
|
+
### Default: passkey DID with a derived signing key
|
|
54
|
+
|
|
55
|
+
The DID is the passkey's own P-256 key. OrbitDB entries are signed by a
|
|
56
|
+
software key **derived** from the passkey's PRF output (HKDF-SHA256,
|
|
57
|
+
domain-separated by the DID; secp256k1, or Ed25519 with `signingKeyType`).
|
|
58
|
+
The passkey signs the identity document once, binding its key to the derived
|
|
59
|
+
key; that assertion is stored and reused.
|
|
60
|
+
|
|
61
|
+
The derived key sits in OrbitDB's keystore, **unencrypted**, in the
|
|
62
|
+
browser's IndexedDB. `encryptKeystore` does not apply to this path. Anyone who
|
|
63
|
+
can read that storage can sign as the DID until the identity is rotated.
|
|
64
|
+
Without PRF the keystore generates a key instead, which stays on one device.
|
|
65
|
+
|
|
66
|
+
### Keystore Provider (`useKeystoreDID` + `encryptKeystore`)
|
|
67
|
+
|
|
68
|
+
The DID is a keystore key (Ed25519 or secp256k1). With `encryptKeystore` a
|
|
69
|
+
sealed copy of that key is kept in localStorage, unlocked once per session by
|
|
70
|
+
the passkey (PRF; largeBlob or hmac-secret where supported).
|
|
43
71
|
|
|
44
|
-
|
|
72
|
+
Two things decide whether that means anything at rest:
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
74
|
+
- OrbitDB signs with whatever its keystore returns, and its default keystore
|
|
75
|
+
writes every key to disk. Give `Identities()` the keystore from
|
|
76
|
+
`createSessionKeystore()` (memory only). With any other keystore the
|
|
77
|
+
unlocked key lands in IndexedDB unencrypted, and the provider warns.
|
|
78
|
+
- Without PRF output nothing is sealed. The provider used to fall back to
|
|
79
|
+
the credential id — a value that sits in localStorage in clear — as the
|
|
80
|
+
wrapping seed; since 0.5.4 it does not, and reports
|
|
81
|
+
`encryptionState: { enabled: false, reason: 'prf-unavailable' }`.
|
|
50
82
|
|
|
51
|
-
During an unlocked session
|
|
52
|
-
|
|
53
|
-
keep keystore sessions short.
|
|
83
|
+
During an unlocked session the private key is in page memory in every
|
|
84
|
+
option but varsig.
|
|
54
85
|
|
|
55
86
|
### Identity Metadata Recovery
|
|
56
87
|
|
|
@@ -70,8 +101,14 @@ as a replacement for authenticator-backed key protection.
|
|
|
70
101
|
|
|
71
102
|
- Serve production applications over HTTPS.
|
|
72
103
|
- Configure WebAuthn relying party IDs and origins deliberately.
|
|
73
|
-
- Prefer the varsig provider
|
|
74
|
-
|
|
104
|
+
- Prefer the varsig provider where a prompt per write is acceptable; nothing
|
|
105
|
+
else keeps the signing key out of JavaScript.
|
|
106
|
+
- With the keystore provider, use `encryptKeystore` together with
|
|
107
|
+
`createSessionKeystore()`, and check `provider.encryptionState` before
|
|
108
|
+
telling users their key is encrypted.
|
|
109
|
+
- Verify identities and entries the way a peer would (see
|
|
110
|
+
`examples/shared/lib/verification.js`); a badge that checks nothing is
|
|
111
|
+
worse than none.
|
|
75
112
|
- Keep dependencies updated and review `npm audit --omit=dev` output before
|
|
76
113
|
production deployments.
|
|
77
114
|
- Do not log private keys, PRF seeds, decrypted keystore archives, or raw secret
|
package/docs/API.md
CHANGED
|
@@ -132,7 +132,7 @@ Creates a signer instance from credential metadata.
|
|
|
132
132
|
```js
|
|
133
133
|
const provider = new WebAuthnDIDProvider(credential);
|
|
134
134
|
const signature = await provider.sign(new TextEncoder().encode('hello'));
|
|
135
|
-
const valid = await provider.verify(signature);
|
|
135
|
+
const valid = await provider.verify(signature, data, credential.publicKey);
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
### OrbitDB Identity Provider
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@le-space/orbitdb-identity-provider-webauthn-did",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
package/src/errors.js
CHANGED
|
@@ -72,6 +72,20 @@ export class KeystoreEncryptionError extends WebAuthnIdentityError {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The authenticator produced no PRF output. Nothing else can stand in for
|
|
77
|
+
* it: a key derived from a value anyone can read (the credential id used to
|
|
78
|
+
* be that stand-in) is not a secret, so callers decide what to do without
|
|
79
|
+
* PRF instead of getting a "wrapped" key that is not.
|
|
80
|
+
*/
|
|
81
|
+
export class PrfUnavailableError extends KeystoreEncryptionError {
|
|
82
|
+
constructor(message = 'PRF output unavailable', options) {
|
|
83
|
+
super(message, options);
|
|
84
|
+
this.name = 'PrfUnavailableError';
|
|
85
|
+
this.code = 'PRF_UNAVAILABLE';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
export class VarsigVerificationError extends WebAuthnIdentityError {
|
|
76
90
|
constructor(message, options = {}) {
|
|
77
91
|
super(message, {
|
package/src/index.js
CHANGED
|
@@ -291,6 +291,30 @@ export {
|
|
|
291
291
|
extensionSupportFromCredential,
|
|
292
292
|
} from './keystore/encryption.js';
|
|
293
293
|
|
|
294
|
+
export { clearIdentityProofs } from './webauthn/identity-proof-store.js';
|
|
295
|
+
export {
|
|
296
|
+
createSessionKeystore,
|
|
297
|
+
isSessionKeystore,
|
|
298
|
+
} from './keystore/session-keystore.js';
|
|
299
|
+
export { PrfUnavailableError } from './errors.js';
|
|
300
|
+
// Restoring on a device that has stored nothing (#61): the fixed PRF input,
|
|
301
|
+
// the public key recovered from two assertions, and the two together.
|
|
302
|
+
export {
|
|
303
|
+
PRF_INPUT_INFO,
|
|
304
|
+
prfInputForRelyingParty,
|
|
305
|
+
} from './webauthn/prf-input.js';
|
|
306
|
+
export {
|
|
307
|
+
recoverPublicKey,
|
|
308
|
+
recoverPublicKeyCandidates,
|
|
309
|
+
} from './webauthn/recovery.js';
|
|
310
|
+
export { restoreIdentityFromAuthenticator } from './webauthn/restore.js';
|
|
311
|
+
// The two halves of it, for a caller that already holds one: a PRF output, or
|
|
312
|
+
// a credential it wants the output for.
|
|
313
|
+
export {
|
|
314
|
+
deriveSigningKeyBytes,
|
|
315
|
+
getPrfOutput,
|
|
316
|
+
} from './keystore/derived-signing-key.js';
|
|
317
|
+
|
|
294
318
|
export default {
|
|
295
319
|
WebAuthnDIDProvider,
|
|
296
320
|
OrbitDBWebAuthnIdentityProvider,
|