@le-space/orbitdb-identity-provider-webauthn-did 0.9.0 → 0.10.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,54 @@
1
1
  # Changes
2
2
 
3
+ ## 0.10.0
4
+
5
+ An identity that is nobody's passkey, so that every security key of an owner can write as the same
6
+ one; and the varsig registration's half of 0.9.1's fix, which #83 had entered under 0.9.1 after
7
+ 0.9.1 was already published from #81.
8
+
9
+ ### Added
10
+
11
+ - **`createSecretSigner(secret, { info })`**: an Ed25519 signer derived from at least 32 bytes of
12
+ secret with HKDF-SHA-256 under `info`, its DID the key's `did:key`. Kept in a vault
13
+ (`createVault`), the secret gives every key that opens a slot the same identity — the root of a
14
+ database's `OrbitDBAccessController`, which OrbitDB fixes at creation and a passkey identity
15
+ would hold for good. In `./standalone` and `./keystore` as well.
16
+ - **`OrbitDBWebAuthnIdentityProviderFunction({ signer })` needs no `webauthnCredential`.** A signer
17
+ is the whole identity, and no passkey stands behind it. Without either, the provider refuses
18
+ instead of failing on the missing credential.
19
+
20
+ ### Fixed
21
+
22
+ - The varsig registration — `WebAuthnVarsigProvider.createCredential`, `createWebAuthnSigner` and
23
+ the signers built on it, and `createWebAuthnEd25519Credential` — honours `authenticatorType` too,
24
+ which it dropped the same way (#83). It still requires user verification: its code asked for
25
+ `'preferred'`, which never reached the browser either, and a varsig signature without the UV flag
26
+ is refused when it is made and when it is verified.
27
+
28
+ ### Changed
29
+
30
+ - `createEd25519DidFromPublicKey` lives in `src/keystore/` and is re-exported from where it was, so
31
+ the main entry can build a `did:key` without importing the worker client.
32
+
33
+ ## 0.9.1
34
+
35
+ `createCredential` honours the choice of authenticator that its options, its types and
36
+ `docs/API.md` have offered all along.
37
+
38
+ ### Fixed
39
+
40
+ - `authenticatorType` (`'platform'`, `'cross-platform'` or `'any'`) and its WebAuthn name
41
+ `authenticatorAttachment` reach the browser. Since 0.2.8 neither did: `buildAuthenticatorSelection`
42
+ dropped them, so the request named no attachment and the browser offered every kind of
43
+ authenticator, a security key as much as the device's own — though the code asked for
44
+ `'platform'`. That stays the default, so nothing changes for a caller that passes neither.
45
+ - An unknown value, or the two disagreeing, is a `TypeError` before anyone is asked, instead of
46
+ being ignored.
47
+
48
+ Tested in Chromium with a virtual authenticator on the USB transport: offered by default, asked for
49
+ with `'cross-platform'`, never asked with `'platform'`. The vault test from 0.9.0 now also runs with
50
+ two such security keys.
51
+
3
52
  ## 0.9.0
4
53
 
5
54
  What applications kept writing for themselves, once and here: one read of the PRF output for every
package/README.md CHANGED
@@ -19,6 +19,7 @@ This package provides:
19
19
  - Authentication/assertion requests omit `allowCredentials` by default, so the browser/authenticator can resolve the credential discoverably.
20
20
  - You can switch this centrally with `configureWebAuthn({ discoverableCredentials: true|false })`.
21
21
  - Registration is still the point where this package extracts the credential public key from attestation.
22
+ - Registration offers every kind of authenticator — the device's own, a security key such as a YubiKey, a phone — unless `authenticatorType: 'platform' | 'cross-platform'` narrows it, in `createCredential` as in the varsig registration and the standalone signers (honoured since 0.9.1). It always requires user verification.
22
23
  - Later `navigator.credentials.get()` assertions do not reliably return the public key again, so identity reconstruction still needs metadata from somewhere else.
23
24
 
24
25
  Since **0.6.0** the passkey alone is enough, and stored metadata is a convenience rather than a requirement:
@@ -227,6 +228,8 @@ const withSecondKey = await addSlot(vault, vaultKey, secondKeysSlot);
227
228
 
228
229
  How it works, and what a vault does not do: [docs/API.md](docs/API.md#prf-keys-and-vaults).
229
230
 
231
+ Since **0.10.0**, a secret kept in that vault can be an identity of its own: `createSecretSigner(secret, { info })` turns it into an Ed25519 signer for the provider's `signer` option, so every key that opens the vault writes as the same identity — the root of a database's access controller, which a single passkey's identity would hold for good. See [docs/API.md](docs/API.md#an-identity-that-is-nobodys-passkey-createsecretsignersecret--info-).
232
+
230
233
  ### Keystore-based DID (WebAuthn + OrbitDB keystore)
231
234
 
232
235
  ```mermaid
package/docs/API.md CHANGED
@@ -61,7 +61,17 @@ Options:
61
61
  - `encryptKeystore?: boolean`
62
62
  - `keystoreEncryptionMethod?: 'prf' | 'largeBlob' | 'hmac-secret'`
63
63
  - `discoverableCredentials?: boolean`
64
- - `authenticatorType?: 'platform' | 'cross-platform' | 'any'`
64
+ - `authenticatorType?: 'platform' | 'cross-platform' | 'any'` — which
65
+ authenticators the browser offers: the device's own (Touch ID, Windows
66
+ Hello, a phone's screen lock), a security key or another phone, or every
67
+ kind. `'any'` is the default and names none in the request.
68
+ - `authenticatorAttachment?: 'platform' | 'cross-platform'` — the WebAuthn name
69
+ for the same choice. Giving both with different values, or a value neither
70
+ knows, throws a `TypeError` before anyone is asked.
71
+
72
+ Before 0.9.1 both were listed here and neither reached the browser: the request
73
+ named no attachment, so every kind of authenticator was offered — a YubiKey as
74
+ much as the device's own. That is still the default.
65
75
 
66
76
  ```js
67
77
  const credential = await WebAuthnDIDProvider.createCredential({
@@ -376,6 +386,50 @@ What a vault does not do:
376
386
  `tests/vault-two-authenticators.test.js` runs this in Chromium with two virtual
377
387
  authenticators, each with its own PRF secret as two YubiKeys have.
378
388
 
389
+ #### An identity that is nobody's passkey: `createSecretSigner(secret, { info })`
390
+
391
+ Since 0.10.0. A database whose access controller is rooted at one passkey's
392
+ identity belongs to that passkey for good: OrbitDB fixes who may grant write
393
+ access when a database is created, and a second security key is a second
394
+ identity. Give the database an identity of its own instead, and let every key
395
+ act as it: keep a random secret in the vault, and turn it into a signer.
396
+
397
+ ```js
398
+ // `secret`: 32 random bytes the application keeps in its vault's payload,
399
+ // so that any slot — any of the owner's keys — gets them back.
400
+ const books = await createSecretSigner(secret, {
401
+ info: 'invoice/books-identity/v1',
402
+ });
403
+
404
+ const keystore = await createSessionKeystore({ signer: books });
405
+ const identities = await Identities({ ipfs, keystore });
406
+ const identity = await identities.createIdentity({
407
+ provider: OrbitDBWebAuthnIdentityProviderFunction({ signer: books }),
408
+ });
409
+ const orbitdb = await createOrbitDB({ ipfs, identities, identity });
410
+ const db = await orbitdb.open('books', {
411
+ AccessController: OrbitDBAccessController({ write: [books.did] }),
412
+ });
413
+ ```
414
+
415
+ - The key is Ed25519, derived with HKDF-SHA-256 from at least 32 bytes of
416
+ secret under `info`, and the DID is its `did:key`. The same secret and
417
+ `info` give the same key, DID and identity document on every device and
418
+ through every slot; another `info` gives another identity. Version `info`.
419
+ - What comes back is `{ did, type: 'Ed25519', publicKey, sign(data) }`, the
420
+ shape the `signer` option takes. The private key stays inside `sign`.
421
+ - With `signer`, `OrbitDBWebAuthnIdentityProviderFunction` needs no
422
+ `webauthnCredential`: no passkey stands behind this identity, and nothing
423
+ asks one. Peers verify it as any signer identity — the DID's key is the
424
+ identity's public key.
425
+ - People who should write without holding the secret are granted access under
426
+ their own identities (`db.access.grant('write', did)`), which can be revoked.
427
+ The secret's identity, as the root, cannot; against a stolen key, make a new
428
+ vault with a new secret and move the data.
429
+
430
+ `tests/secret-signer.test.js` writes through two keys as the same root and has
431
+ an identity from another secret refused.
432
+
379
433
  ### largeBlob Metadata Helpers
380
434
 
381
435
  These helpers encode and recover identity metadata used by the demo recovery
@@ -492,6 +546,17 @@ const credential = await WebAuthnVarsigProvider.createCredential({
492
546
  const identity = await createWebAuthnVarsigIdentity({ credential });
493
547
  ```
494
548
 
549
+ `WebAuthnVarsigProvider.createCredential(options)` takes `userId`,
550
+ `displayName`, `domain`, `discoverableCredentials`, `forceP256` (offer only
551
+ P-256), and `authenticatorType` / `authenticatorAttachment` as
552
+ `WebAuthnDIDProvider.createCredential` does. Before 0.9.1 it dropped the choice
553
+ of authenticator the same way and offered every kind.
554
+
555
+ It always requires user verification, whatever the options say. A varsig
556
+ signature without the UV flag is refused, when it is made and when it is
557
+ verified, so a credential registered without user verification could never
558
+ sign.
559
+
495
560
  ## Standalone Export
496
561
 
497
562
  Use the standalone export when you want WebAuthn signing and worker-keystore
@@ -515,6 +580,10 @@ import {
515
580
  - `createWebAuthnEd25519Credential(userId, displayName, options?)`
516
581
  - `checkEd25519Support()`
517
582
 
583
+ The `create…Signer` functions register as
584
+ `WebAuthnVarsigProvider.createCredential` does and take its options;
585
+ `createWebAuthnEd25519Credential` passes on `options.authenticatorType` alone.
586
+
518
587
  ```js
519
588
  const signer = await createWebAuthnSigner({
520
589
  userId: 'alice@example.com',
@@ -552,8 +621,9 @@ const ucanSigner = signer.toUcantoSigner({
552
621
 
553
622
  The same functions as the root export, without OrbitDB: `readPrfOutput`,
554
623
  `deriveSubkey`, `deriveAesKey`, `createVault`, `openVault`, `addSlot`,
555
- `removeSlot`, `replacePayload`, `slotIdFor`, `PrfUnavailableError` and
556
- `VaultError`. See [PRF Keys and Vaults](#prf-keys-and-vaults).
624
+ `removeSlot`, `replacePayload`, `slotIdFor`, `createSecretSigner`,
625
+ `PrfUnavailableError` and `VaultError`. See
626
+ [PRF Keys and Vaults](#prf-keys-and-vaults).
557
627
 
558
628
  ### P-256 Wallet Primitives
559
629
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/orbitdb-identity-provider-webauthn-did",
3
- "version": "0.9.0",
3
+ "version": "0.10.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/index.js CHANGED
@@ -329,6 +329,9 @@ export {
329
329
  replacePayload,
330
330
  slotIdFor,
331
331
  } from './keystore/vault.js';
332
+ // An identity that is nobody's passkey — the books every key of their owner
333
+ // writes as, its secret kept in a vault.
334
+ export { createSecretSigner } from './keystore/secret-signer.js';
332
335
 
333
336
  export default {
334
337
  WebAuthnDIDProvider,
@@ -0,0 +1,26 @@
1
+ import { varint } from 'multiformats';
2
+ import { base58btc } from 'multiformats/bases/base58';
3
+
4
+ /**
5
+ * Build a did:key identifier from raw Ed25519 public key bytes.
6
+ * @param {Uint8Array} publicKeyBytes
7
+ * @returns {string}
8
+ */
9
+ export function createEd25519DidFromPublicKey(publicKeyBytes) {
10
+ if (!(publicKeyBytes instanceof Uint8Array) || publicKeyBytes.length !== 32) {
11
+ throw new Error(
12
+ `Invalid Ed25519 public key length: ${publicKeyBytes?.length || 0}`
13
+ );
14
+ }
15
+
16
+ const ED25519_MULTICODEC = 0xed;
17
+ const codecLength = varint.encodingLength(ED25519_MULTICODEC);
18
+ const codecBytes = new Uint8Array(codecLength);
19
+ varint.encodeTo(ED25519_MULTICODEC, codecBytes, 0);
20
+
21
+ const multikey = new Uint8Array(codecBytes.length + publicKeyBytes.length);
22
+ multikey.set(codecBytes, 0);
23
+ multikey.set(publicKeyBytes, codecBytes.length);
24
+
25
+ return `did:key:${base58btc.encode(multikey)}`;
26
+ }
@@ -5,4 +5,5 @@ export * from './encryption.js';
5
5
  export { default } from './encryption.js';
6
6
  export * from './provider.js';
7
7
  export * from './session-keystore.js';
8
+ export { createSecretSigner } from './secret-signer.js';
8
9
  export { PrfUnavailableError } from '../errors.js';
@@ -46,7 +46,9 @@ function normalizeByteArray(value) {
46
46
  export class OrbitDBWebAuthnIdentityProvider {
47
47
  /**
48
48
  * @param {Object} options - Provider configuration.
49
- * @param {Object} options.webauthnCredential - WebAuthn credential info.
49
+ * @param {Object} [options.webauthnCredential] - WebAuthn credential info.
50
+ * Required unless `signer` is given: a signer is the whole identity, and
51
+ * no passkey stands behind it (`createSecretSigner`).
50
52
  * @param {boolean} [options.useKeystoreDID=false] - Use keystore DID instead of WebAuthn DID.
51
53
  * @param {Object|null} [options.keystore=null] - OrbitDB keystore instance.
52
54
  * @param {string} [options.keystoreKeyType='secp256k1'] - Keystore key type.
@@ -82,6 +84,11 @@ export class OrbitDBWebAuthnIdentityProvider {
82
84
  prfOutput = null,
83
85
  }) {
84
86
  if (signer) assertSigner(signer);
87
+ if (!signer && !webauthnCredential) {
88
+ throw new TypeError(
89
+ 'the provider needs a webauthnCredential, or a signer that is the identity'
90
+ );
91
+ }
85
92
  if (
86
93
  prfOutput != null &&
87
94
  !(prfOutput instanceof Uint8Array && prfOutput.length >= 32)
@@ -104,7 +111,9 @@ export class OrbitDBWebAuthnIdentityProvider {
104
111
  this.deriveSigningKeyFromPrf = deriveSigningKeyFromPrf;
105
112
  // Held only until the signing key is in the keystore; see getId().
106
113
  this.prfOutput = prfOutput;
107
- this.webauthnProvider = new WebAuthnDIDProvider(webauthnCredential);
114
+ this.webauthnProvider = webauthnCredential
115
+ ? new WebAuthnDIDProvider(webauthnCredential)
116
+ : null;
108
117
  this.type = IDENTITY_TYPES.WEBAUTHN; // Set instance property
109
118
  this.useKeystoreDID = useKeystoreDID; // Flag to use Ed25519 DID from keystore
110
119
  this.keystore = keystore; // OrbitDB keystore instance
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module SecretSigner
3
+ * @description
4
+ * An identity that is nobody's passkey: the books of an application, which
5
+ * every registered security key of their owner may write to.
6
+ *
7
+ * A passkey identity belongs to one authenticator — its DID is the
8
+ * credential's key, its signing key comes from that authenticator's PRF
9
+ * output — so a second security key is a second identity. OrbitDB's access
10
+ * controllers fix who may grant write access when a database is created, so
11
+ * a database rooted at one key's identity stays that key's forever. The books
12
+ * get an identity of their own instead, and every key acts as it:
13
+ * the application keeps a random secret in a vault (`createVault`), any slot
14
+ * opens it, and this turns the secret into the books' signing key. Other
15
+ * people are not given the secret; they are granted write access under their
16
+ * own identities, which can be revoked.
17
+ *
18
+ * The key is Ed25519, derived with HKDF-SHA-256 under `info`; its DID is the
19
+ * key's `did:key`. The same secret and `info` give the same key, DID and
20
+ * OrbitDB identity document on every device and for every slot. Use it as
21
+ * `createSessionKeystore({ signer })` and
22
+ * `OrbitDBWebAuthnIdentityProviderFunction({ signer })`.
23
+ */
24
+ import { generateKeyPairFromSeed } from '@libp2p/crypto/keys';
25
+ import { deriveSubkey } from './prf-keys.js';
26
+ import { createEd25519DidFromPublicKey } from './ed25519-did.js';
27
+
28
+ const MIN_SECRET_BYTES = 32;
29
+
30
+ /**
31
+ * An Ed25519 signer derived from a secret, in the shape the provider's
32
+ * `signer` option and `createSessionKeystore` take.
33
+ *
34
+ * The private key stays in this closure: what comes back can sign, but has no
35
+ * field that holds the key.
36
+ *
37
+ * @param {Uint8Array} secret - At least 32 random bytes, e.g. from a vault.
38
+ * @param {Object} options
39
+ * @param {string} options.info - Domain separation, part of the identity:
40
+ * another `info` is another key and another DID. Version it
41
+ * (`invoice/books-identity/v1`).
42
+ * @returns {Promise<{ did: string, type: 'Ed25519', publicKey: Uint8Array,
43
+ * sign: (data: Uint8Array) => Promise<Uint8Array> }>}
44
+ */
45
+ export async function createSecretSigner(secret, { info } = {}) {
46
+ if (!(secret instanceof Uint8Array) || secret.length < MIN_SECRET_BYTES) {
47
+ throw new TypeError(
48
+ `createSecretSigner needs a secret of at least ${MIN_SECRET_BYTES} bytes`
49
+ );
50
+ }
51
+ if (typeof info !== 'string' || info.length === 0) {
52
+ throw new TypeError('createSecretSigner needs a non-empty info string');
53
+ }
54
+ const seed = await deriveSubkey(secret, info);
55
+ const key = await generateKeyPairFromSeed('Ed25519', seed);
56
+ seed.fill(0);
57
+ const publicKey = key.publicKey.raw;
58
+ return {
59
+ did: createEd25519DidFromPublicKey(publicKey),
60
+ type: 'Ed25519',
61
+ publicKey,
62
+ sign: async (data) => key.sign(data),
63
+ };
64
+ }
@@ -47,6 +47,7 @@ export {
47
47
  slotIdFor,
48
48
  } from '../keystore/vault.js';
49
49
  export { PrfUnavailableError, VaultError } from '../errors.js';
50
+ export { createSecretSigner } from '../keystore/secret-signer.js';
50
51
 
51
52
  export {
52
53
  createWorkerKeystoreClient,
@@ -1,5 +1,9 @@
1
- import { varint } from 'multiformats';
2
- import { base58btc } from 'multiformats/bases/base58';
1
+ // Lives in keystore/ so the main entry can build an Ed25519 did:key without
2
+ // importing this module, whose `new Worker(new URL(…))` would make bundlers
3
+ // pack the worker into every app.
4
+ import { createEd25519DidFromPublicKey } from '../../keystore/ed25519-did.js';
5
+
6
+ export { createEd25519DidFromPublicKey };
3
7
 
4
8
  /**
5
9
  * Convert a Uint8Array into a detached ArrayBuffer slice.
@@ -59,30 +63,6 @@ function normalizeArchiveAfterDeserialization(archive) {
59
63
  return archive;
60
64
  }
61
65
 
62
- /**
63
- * Build a did:key identifier from raw Ed25519 public key bytes.
64
- * @param {Uint8Array} publicKeyBytes
65
- * @returns {string}
66
- */
67
- export function createEd25519DidFromPublicKey(publicKeyBytes) {
68
- if (!(publicKeyBytes instanceof Uint8Array) || publicKeyBytes.length !== 32) {
69
- throw new Error(
70
- `Invalid Ed25519 public key length: ${publicKeyBytes?.length || 0}`
71
- );
72
- }
73
-
74
- const ED25519_MULTICODEC = 0xed;
75
- const codecLength = varint.encodingLength(ED25519_MULTICODEC);
76
- const codecBytes = new Uint8Array(codecLength);
77
- varint.encodeTo(ED25519_MULTICODEC, codecBytes, 0);
78
-
79
- const multikey = new Uint8Array(codecBytes.length + publicKeyBytes.length);
80
- multikey.set(codecBytes, 0);
81
- multikey.set(publicKeyBytes, codecBytes.length);
82
-
83
- return `did:key:${base58btc.encode(multikey)}`;
84
- }
85
-
86
66
  /**
87
67
  * @returns {boolean}
88
68
  */
@@ -4,7 +4,10 @@
4
4
  import { DIDKey } from 'iso-did';
5
5
  import { parseAttestationObject } from 'iso-passkeys';
6
6
  import { toArrayBuffer } from './utils.js';
7
- import { buildAuthenticatorSelection } from '../webauthn/config.js';
7
+ import {
8
+ buildAuthenticatorSelection,
9
+ resolveAuthenticatorAttachment,
10
+ } from '../webauthn/config.js';
8
11
  import { logWebAuthnResponse } from '../webauthn/debug-log.js';
9
12
 
10
13
  /**
@@ -62,6 +65,13 @@ function extractCredentialInfo(attestationObject) {
62
65
  * @param {string} [options.displayName] - Display name.
63
66
  * @param {string} [options.domain] - RP ID / domain.
64
67
  * @param {boolean} [options.discoverableCredentials] - Override global discoverable credential policy.
68
+ * @param {'platform'|'cross-platform'|'any'} [options.authenticatorType='any'] -
69
+ * Which authenticators the browser offers, as for
70
+ * `WebAuthnDIDProvider.createCredential`. `'any'` names none in the
71
+ * request, which is what this has sent since 0.2.8.
72
+ * @param {'platform'|'cross-platform'} [options.authenticatorAttachment] -
73
+ * The WebAuthn name for the same choice; refused if it disagrees with
74
+ * `authenticatorType`.
65
75
  * @returns {Promise<Object>} Credential info including public key and DID.
66
76
  */
67
77
  async function createWebAuthnVarsigCredential(options = {}) {
@@ -69,7 +79,6 @@ async function createWebAuthnVarsigCredential(options = {}) {
69
79
  userId,
70
80
  displayName,
71
81
  domain,
72
- authenticatorType = 'any',
73
82
  forceP256 = false,
74
83
  } = {
75
84
  userId: `orbitdb-user-${Date.now()}`,
@@ -78,6 +87,9 @@ async function createWebAuthnVarsigCredential(options = {}) {
78
87
  ...options,
79
88
  };
80
89
 
90
+ // Before any prompt: a choice this cannot honour is the caller's mistake.
91
+ const authenticatorAttachment = resolveAuthenticatorAttachment(options);
92
+
81
93
  const publicKey = {
82
94
  rp: { name: 'OrbitDB Varsig Identity', id: domain },
83
95
  user: {
@@ -94,13 +106,19 @@ async function createWebAuthnVarsigCredential(options = {}) {
94
106
  { type: 'public-key', alg: -7 },
95
107
  ],
96
108
  attestation: 'none',
97
- authenticatorSelection: buildAuthenticatorSelection({
98
- ...options,
99
- ...(authenticatorType !== 'any' && {
100
- authenticatorAttachment: authenticatorType,
101
- }),
102
- userVerification: 'preferred',
103
- }),
109
+ // This named the attachment and `userVerification: 'preferred'` from March
110
+ // to October 2026 without effect: buildAuthenticatorSelection passed on
111
+ // neither, so every kind of authenticator was offered and user
112
+ // verification was required. Every kind stays the default;
113
+ // `authenticatorType` narrows it. Verification stays required: a varsig
114
+ // signature without the UV flag is refused — `verifyWebAuthnAssertion`
115
+ // insists on it, when signing and when verifying — and asking for less
116
+ // would let an authenticator that cannot verify the user register here
117
+ // and then never sign.
118
+ authenticatorSelection: {
119
+ ...buildAuthenticatorSelection(options),
120
+ ...(authenticatorAttachment && { authenticatorAttachment }),
121
+ },
104
122
  // Recovery writes the varsig metadata into the passkey's largeBlob after
105
123
  // registration — which an authenticator only allows for a credential that
106
124
  // was created asking for it. `preferred` costs nothing where the
@@ -44,18 +44,75 @@ export function resolveWebAuthnConfig(options = {}) {
44
44
  };
45
45
  }
46
46
 
47
+ const AUTHENTICATOR_TYPES = ['platform', 'cross-platform', 'any'];
48
+ const AUTHENTICATOR_ATTACHMENTS = ['platform', 'cross-platform'];
49
+
50
+ /**
51
+ * The attachment a registration asks for: `authenticatorType` —
52
+ * `'platform'` (the device's own authenticator), `'cross-platform'` (a
53
+ * security key or a phone) or `'any'` — or its WebAuthn name
54
+ * `authenticatorAttachment`, which has no word for "any".
55
+ *
56
+ * A value this does not know is refused rather than ignored: a typo would
57
+ * otherwise quietly offer every authenticator, or none the caller meant.
58
+ *
59
+ * @param {Object} [options]
60
+ * @param {'platform'|'cross-platform'|'any'} [options.authenticatorType]
61
+ * @param {'platform'|'cross-platform'} [options.authenticatorAttachment]
62
+ * @param {'platform'|'cross-platform'|'any'} [fallback='any'] - When neither
63
+ * is given.
64
+ * @returns {'platform'|'cross-platform'|undefined} `undefined` for any
65
+ * authenticator: the request then names none.
66
+ */
67
+ export function resolveAuthenticatorAttachment(
68
+ { authenticatorType, authenticatorAttachment } = {},
69
+ fallback = 'any'
70
+ ) {
71
+ if (
72
+ authenticatorType != null &&
73
+ !AUTHENTICATOR_TYPES.includes(authenticatorType)
74
+ ) {
75
+ throw new TypeError(
76
+ `authenticatorType must be one of ${AUTHENTICATOR_TYPES.join(', ')}, not '${authenticatorType}'`
77
+ );
78
+ }
79
+ if (
80
+ authenticatorAttachment != null &&
81
+ !AUTHENTICATOR_ATTACHMENTS.includes(authenticatorAttachment)
82
+ ) {
83
+ throw new TypeError(
84
+ `authenticatorAttachment must be one of ${AUTHENTICATOR_ATTACHMENTS.join(', ')}, not '${authenticatorAttachment}'`
85
+ );
86
+ }
87
+ if (
88
+ authenticatorType != null &&
89
+ authenticatorAttachment != null &&
90
+ authenticatorType !== authenticatorAttachment
91
+ ) {
92
+ throw new TypeError(
93
+ `authenticatorType '${authenticatorType}' and authenticatorAttachment '${authenticatorAttachment}' disagree; give one`
94
+ );
95
+ }
96
+ const choice = authenticatorType ?? authenticatorAttachment ?? fallback;
97
+ return choice === 'any' ? undefined : choice;
98
+ }
99
+
100
+ /**
101
+ * `authenticatorSelection` for a registration. Only the discoverable-credential
102
+ * policy comes from `options` and `configureWebAuthn`, and user verification is
103
+ * always required; an `authenticatorAttachment` or `userVerification` in
104
+ * `options` is not read. This used to destructure both as if it read them,
105
+ * which is how two registrations came to name values that never reached the
106
+ * browser. A registration that wants an attachment adds it itself — see
107
+ * `resolveAuthenticatorAttachment`.
108
+ */
47
109
  export function buildAuthenticatorSelection(options = {}) {
48
- const {
49
- discoverableCredentials,
50
- authenticatorAttachment,
51
- userVerification = 'required',
52
- } = resolveWebAuthnConfig(options);
110
+ const { discoverableCredentials } = resolveWebAuthnConfig(options);
53
111
 
54
112
  return {
55
- ...(authenticatorAttachment ? { authenticatorAttachment } : {}),
56
113
  requireResidentKey: discoverableCredentials,
57
114
  residentKey: discoverableCredentials ? 'required' : 'discouraged',
58
- userVerification,
115
+ userVerification: 'required',
59
116
  };
60
117
  }
61
118
 
@@ -24,6 +24,7 @@ import {
24
24
  } from '../errors.js';
25
25
  import {
26
26
  buildAuthenticatorSelection,
27
+ resolveAuthenticatorAttachment,
27
28
  buildCredentialRequestOptions,
28
29
  } from './config.js';
29
30
  import { logWebAuthnResponse } from './debug-log.js';
@@ -115,6 +116,13 @@ export class WebAuthnDIDProvider {
115
116
  * @param {boolean} options.encryptKeystore - Enable keystore encryption
116
117
  * @param {string} options.keystoreEncryptionMethod - 'prf' (default), 'hmac-secret', or 'largeBlob'
117
118
  * @param {boolean} [options.discoverableCredentials] - Override global discoverable credential policy
119
+ * @param {'platform'|'cross-platform'|'any'} [options.authenticatorType='any'] -
120
+ * Which authenticators the browser offers: the device's own, a security
121
+ * key or phone, or every kind. `'any'` names none in the request, which is
122
+ * what this has sent since 0.2.8.
123
+ * @param {'platform'|'cross-platform'} [options.authenticatorAttachment] -
124
+ * The WebAuthn name for the same choice; refused if it disagrees with
125
+ * `authenticatorType`.
118
126
  * @returns {Promise<Object>} Credential info with public key and metadata.
119
127
  */
120
128
  static async createCredential(options = {}) {
@@ -131,10 +139,14 @@ export class WebAuthnDIDProvider {
131
139
  ...options,
132
140
  };
133
141
 
142
+ // Before any prompt: a choice this cannot honour is the caller's mistake.
143
+ const authenticatorAttachment = resolveAuthenticatorAttachment(options);
144
+
134
145
  webauthnLog('createCredential() called with options: %o', {
135
146
  userId,
136
147
  displayName,
137
148
  domain,
149
+ authenticatorAttachment: authenticatorAttachment ?? 'any',
138
150
  });
139
151
 
140
152
  if (!this.isSupported()) {
@@ -177,11 +189,14 @@ export class WebAuthnDIDProvider {
177
189
  { alg: -7, type: 'public-key' }, // ES256 (P-256 curve)
178
190
  { alg: -257, type: 'public-key' }, // RS256 fallback
179
191
  ],
180
- authenticatorSelection: buildAuthenticatorSelection({
181
- ...options,
182
- authenticatorAttachment: 'platform',
183
- userVerification: 'required',
184
- }),
192
+ // This named 'platform' from March to October 2026 without effect:
193
+ // buildAuthenticatorSelection never passed it on, so every kind of
194
+ // authenticator was offered, security keys included. That stays the
195
+ // default; `authenticatorType` narrows it.
196
+ authenticatorSelection: {
197
+ ...buildAuthenticatorSelection(options),
198
+ ...(authenticatorAttachment && { authenticatorAttachment }),
199
+ },
185
200
  timeout: 60000,
186
201
  attestation: 'none', // Don't need attestation for DID creation
187
202
  },
package/types/index.d.ts CHANGED
@@ -131,8 +131,14 @@ export interface CreateCredentialOptions {
131
131
  encryptKeystore?: boolean;
132
132
  keystoreEncryptionMethod?: KeystoreEncryptionMethod;
133
133
  discoverableCredentials?: boolean;
134
+ /**
135
+ * Which authenticators the browser offers: the device's own, a security key
136
+ * or phone, or every kind. Default `'any'`: the request names none.
137
+ */
134
138
  authenticatorType?: AuthenticatorType;
139
+ /** The WebAuthn name for `authenticatorType`; the two must not disagree. */
135
140
  authenticatorAttachment?: AuthenticatorAttachment;
141
+ /** Not read: a registration always requires user verification. */
136
142
  userVerification?: UserVerificationRequirement;
137
143
  [key: string]: unknown;
138
144
  }
@@ -140,6 +146,7 @@ export interface CreateCredentialOptions {
140
146
  export interface WebAuthnConfig {
141
147
  discoverableCredentials?: boolean;
142
148
  userVerification?: UserVerificationRequirement;
149
+ /** Not read by `configureWebAuthn`; give `createCredential` an `authenticatorType`. */
143
150
  authenticatorAttachment?: AuthenticatorAttachment;
144
151
  residentKey?: ResidentKeyRequirement;
145
152
  requireResidentKey?: boolean;
@@ -612,6 +619,22 @@ export function replacePayload(
612
619
  /** The slot id an authenticator is filed under. */
613
620
  export function slotIdFor(rawCredentialId: Uint8Array): Promise<string>;
614
621
 
622
+ /**
623
+ * An Ed25519 signer derived from a secret — an identity that is nobody's
624
+ * passkey, e.g. the books every key of their owner writes as. Same secret and
625
+ * `info`, same DID everywhere. Use with `createSessionKeystore({ signer })`
626
+ * and the provider's `signer` option.
627
+ */
628
+ export function createSecretSigner(
629
+ secret: Uint8Array,
630
+ options: { info: string }
631
+ ): Promise<{
632
+ did: string;
633
+ type: 'Ed25519';
634
+ publicKey: Uint8Array;
635
+ sign(data: Uint8Array): Promise<Uint8Array>;
636
+ }>;
637
+
615
638
  export type VaultErrorCode =
616
639
  | 'VAULT_MALFORMED'
617
640
  | 'VAULT_NO_SLOT'
@@ -40,6 +40,7 @@ export interface ExtensionSupport {
40
40
  }
41
41
 
42
42
  export interface OrbitDBWebAuthnIdentityProviderOptions {
43
+ /** Required unless `signer` is given. */
43
44
  webauthnCredential?: Record<string, unknown>;
44
45
  keystore?: unknown;
45
46
  usePersistentKey?: boolean;
@@ -95,6 +96,11 @@ export function createSessionKeystore(options?: {
95
96
  signer?: ExternalSigner;
96
97
  }): Promise<unknown>;
97
98
  export function isSessionKeystore(keystore: unknown): boolean;
99
+ /** An Ed25519 signer derived from a secret (HKDF under `info`): an identity that is nobody's passkey. */
100
+ export function createSecretSigner(
101
+ secret: Uint8Array,
102
+ options: { info: string }
103
+ ): Promise<ExternalSigner>;
98
104
  export function assertSigner(signer: unknown): void;
99
105
 
100
106
  /** The authenticator returned no PRF output; nothing stands in for it. */
@@ -26,7 +26,15 @@ export interface WebAuthnVarsigCredential {
26
26
  export interface WebAuthnSignerOptions {
27
27
  userId?: string;
28
28
  displayName?: string;
29
+ /**
30
+ * Which authenticators the browser offers: the device's own, a security key
31
+ * or phone, or every kind. Default `'any'`: the request names none.
32
+ */
29
33
  authenticatorType?: AuthenticatorType;
34
+ /** The WebAuthn name for `authenticatorType`; the two must not disagree. */
35
+ authenticatorAttachment?: AuthenticatorAttachment;
36
+ /** Not read: a registration always requires user verification. */
37
+ userVerification?: UserVerificationRequirement;
30
38
  forceP256?: boolean;
31
39
  domain?: string;
32
40
  [key: string]: unknown;
@@ -381,6 +389,22 @@ export function replacePayload(
381
389
  /** The slot id an authenticator is filed under. */
382
390
  export function slotIdFor(rawCredentialId: Uint8Array): Promise<string>;
383
391
 
392
+ /**
393
+ * An Ed25519 signer derived from a secret — an identity that is nobody's
394
+ * passkey, e.g. the books every key of their owner writes as. Same secret and
395
+ * `info`, same DID everywhere. Use with `createSessionKeystore({ signer })`
396
+ * and the provider's `signer` option.
397
+ */
398
+ export function createSecretSigner(
399
+ secret: Uint8Array,
400
+ options: { info: string }
401
+ ): Promise<{
402
+ did: string;
403
+ type: 'Ed25519';
404
+ publicKey: Uint8Array;
405
+ sign(data: Uint8Array): Promise<Uint8Array>;
406
+ }>;
407
+
384
408
  export type VaultErrorCode =
385
409
  | 'VAULT_MALFORMED'
386
410
  | 'VAULT_NO_SLOT'