From 0ff5c5bca6c0ff45870d645316341b4ea103150d Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 07:38:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(webauthn):=20passkeys=20in=20the=20vault?= =?UTF-8?q?=20=E2=80=94=20dcrypt=20as=20a=20software=20authenticator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A passkey is a P-256 keypair, not a stored password: @decryption/webauthn mints one, keeps the private half as a concealed vault field, and signs the challenge a site issues over authenticatorData || sha256(clientDataJSON). `dcrypt passkey register|list|assert|forget` drives it from the CLI, printing the JSON a WebAuthn relying party expects, so it can be piped into one. Attestation is fmt "none" with an all-zero AAGUID — this is software and says so; a relying party demanding hardware attestation should reject it. --- README.md | 1 + packages/cli/__tests__/passkey.test.ts | 127 ++++++++++++ packages/cli/package.json | 1 + packages/cli/src/commands.ts | 3 + packages/cli/src/commands/passkey.ts | 168 +++++++++++++++ packages/vault/src/types.ts | 3 +- packages/webauthn/README.md | 77 +++++++ .../webauthn/__tests__/authenticator.test.ts | 153 ++++++++++++++ packages/webauthn/__tests__/cbor.test.ts | 44 ++++ packages/webauthn/__tests__/store.test.ts | 121 +++++++++++ packages/webauthn/jest.config.js | 18 ++ packages/webauthn/package.json | 48 +++++ packages/webauthn/src/authenticator.ts | 195 ++++++++++++++++++ packages/webauthn/src/cbor.ts | 142 +++++++++++++ packages/webauthn/src/index.ts | 4 + packages/webauthn/src/store.ts | 135 ++++++++++++ packages/webauthn/src/types.ts | 79 +++++++ packages/webauthn/tsconfig.esm.json | 9 + packages/webauthn/tsconfig.json | 16 ++ .../schemas/dcrypt_vault/passkey_types.sql | 12 ++ pgpm-modules/dcrypt-vault/pgpm.plan | 1 + .../schemas/dcrypt_vault/passkey_types.sql | 29 +++ .../schemas/dcrypt_vault/passkey_types.sql | 7 + pnpm-lock.yaml | 23 +++ 24 files changed, 1415 insertions(+), 1 deletion(-) create mode 100644 packages/cli/__tests__/passkey.test.ts create mode 100644 packages/cli/src/commands/passkey.ts create mode 100644 packages/webauthn/README.md create mode 100644 packages/webauthn/__tests__/authenticator.test.ts create mode 100644 packages/webauthn/__tests__/cbor.test.ts create mode 100644 packages/webauthn/__tests__/store.test.ts create mode 100644 packages/webauthn/jest.config.js create mode 100644 packages/webauthn/package.json create mode 100644 packages/webauthn/src/authenticator.ts create mode 100644 packages/webauthn/src/cbor.ts create mode 100644 packages/webauthn/src/index.ts create mode 100644 packages/webauthn/src/store.ts create mode 100644 packages/webauthn/src/types.ts create mode 100644 packages/webauthn/tsconfig.esm.json create mode 100644 packages/webauthn/tsconfig.json create mode 100644 pgpm-modules/dcrypt-vault/deploy/schemas/dcrypt_vault/passkey_types.sql create mode 100644 pgpm-modules/dcrypt-vault/revert/schemas/dcrypt_vault/passkey_types.sql create mode 100644 pgpm-modules/dcrypt-vault/verify/schemas/dcrypt_vault/passkey_types.sql diff --git a/README.md b/README.md index 2128a25..12d37ef 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ socket is `dcrypt account`, which talks to the Constructive endpoint you name. | **@decryption/keys** | [![npm](https://img.shields.io/npm/v/@decryption/keys.svg)](https://www.npmjs.com/package/@decryption/keys) | [GitHub](./packages/keys) | X25519 identities, recipient strings, on-disk keyring | | **@decryption/secrets** | [![npm](https://img.shields.io/npm/v/@decryption/secrets.svg)](https://www.npmjs.com/package/@decryption/secrets) | [GitHub](./packages/secrets) | Team secrets file format, rekeying and `.env` export | | **@decryption/accounts** | [![npm](https://img.shields.io/npm/v/@decryption/accounts.svg)](https://www.npmjs.com/package/@decryption/accounts) | [GitHub](./packages/accounts) | Constructive accounts and API keys, held in the local vault | +| **@decryption/webauthn** | [![npm](https://img.shields.io/npm/v/@decryption/webauthn.svg)](https://www.npmjs.com/package/@decryption/webauthn) | [GitHub](./packages/webauthn) | A software WebAuthn authenticator: passkeys kept in the vault | | **@decryption/cli** | [![npm](https://img.shields.io/npm/v/@decryption/cli.svg)](https://www.npmjs.com/package/@decryption/cli) | [GitHub](./packages/cli) | The `dcrypt` command-line interface | ### Vendored primitives diff --git a/packages/cli/__tests__/passkey.test.ts b/packages/cli/__tests__/passkey.test.ts new file mode 100644 index 0000000..4b3ff45 --- /dev/null +++ b/packages/cli/__tests__/passkey.test.ts @@ -0,0 +1,127 @@ +import { mkdtempSync, writeFileSync } from 'fs'; +import { Inquirerer, parseArgv } from 'inquirerer'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { dispatch, EXIT } from '../src'; + +/** The weakest Argon2id costs the core accepts — the tests assert behaviour, not work factor. */ +const FAST_KDF = 't=1,m=8192,p=1'; +const CHALLENGE = 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8'; + +jest.setTimeout(300000); + +let home: string; +let work: string; +let out: string[]; +let errors: string[]; + +const run = async (line: string): Promise => { + const argv = parseArgv(['node', 'dcrypt', ...line.split(' ').filter(Boolean)], { + '--': true, + string: ['passphrase-file', 'challenge', 'origin', 'user', 'credential', 'kdf'], + }); + const prompter = new Inquirerer({ noTty: true, useDefaults: true }); + try { + return await dispatch(argv, prompter); + } finally { + prompter.close(); + } +}; + +const stdout = (): string => out.join('').trim(); +const stderr = (): string => errors.join('').trim(); + +const file = (name: string, contents: string): string => { + const path = join(work, name); + writeFileSync(path, contents); + return path; +}; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'dcrypt-home-')); + work = mkdtempSync(join(tmpdir(), 'dcrypt-work-')); + process.env.APPSTASH_BASE_DIR = home; + out = []; + errors = []; + jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + out.push(String(chunk)); + return true; + }); + jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('dcrypt passkey', () => { + it('documents itself', async () => { + expect(await run('passkey help')).toBe(0); + expect(stdout()).toContain('dcrypt passkey '); + expect(stdout()).toContain('cannot be phished'); + }); + + it('refuses to sign a challenge it made up itself', async () => { + const pass = file('pass.txt', 'a strong master password'); + expect( + await run(`passkey register auth.example.com --passphrase-file ${pass} --kdf ${FAST_KDF}`) + ).toBe(EXIT.usage); + expect(stderr()).toContain('--challenge from the site is required'); + }); + + it('registers, lists, signs and forgets — all against the same vault', async () => { + const pass = file('pass.txt', 'a strong master password'); + const vault = `--passphrase-file ${pass} --kdf ${FAST_KDF}`; + + expect( + await run( + `passkey register auth.example.com --user dev@example.com --challenge ${CHALLENGE} ${vault}` + ) + ).toBe(0); + expect(stdout()).toContain('registered dev@example.com at auth.example.com'); + + out = []; + expect(await run(`passkey list ${vault}`)).toBe(0); + expect(stdout()).toContain('dev@example.com'); + expect(stdout()).toContain('used 0×'); + + out = []; + expect( + await run(`passkey assert auth.example.com --challenge ${CHALLENGE} --json ${vault}`) + ).toBe(0); + const assertion = JSON.parse(stdout()) as { + type: string; + response: { signature: string; clientDataJSON: string }; + }; + expect(assertion.type).toBe('public-key'); + expect(assertion.response.signature).toBeTruthy(); + // the origin it signed is the site's, which is what makes it unphishable + expect( + JSON.parse(Buffer.from(assertion.response.clientDataJSON, 'base64url').toString()) + ).toMatchObject({ origin: 'https://auth.example.com', challenge: CHALLENGE }); + + out = []; + expect(await run(`passkey list ${vault}`)).toBe(0); + expect(stdout()).toContain('used 1×'); + + out = []; + expect(await run(`passkey forget auth.example.com ${vault}`)).toBe(0); + out = []; + expect(await run(`passkey list ${vault}`)).toBe(0); + expect(stdout()).toContain('(no passkeys)'); + }); + + it('says when a site has no passkey', async () => { + const pass = file('pass.txt', 'a strong master password'); + expect( + await run( + `passkey assert auth.example.com --challenge ${CHALLENGE} --passphrase-file ${pass} --kdf ${FAST_KDF}` + ) + ).toBe(EXIT.notFound); + expect(stderr()).toContain('no passkey for "auth.example.com"'); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index d43c88c..9125472 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "@decryption/shamir": "workspace:*", "@decryption/vault": "workspace:*", "@decryption/wallet": "workspace:*", + "@decryption/webauthn": "workspace:*", "appstash": "^0.7.0", "inquirerer": "^4.9.1", "yanse": "^0.2.1" diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 3e40e16..d86d11f 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -6,6 +6,7 @@ import { cosmologyCommand } from './commands/cosmology'; import { decryptCommand, encryptCommand } from './commands/encrypt'; import { keychainCommand } from './commands/keychain'; import { keysCommand } from './commands/keys'; +import { passkeyCommand } from './commands/passkey'; import { saltCommand } from './commands/salt'; import { secretsCommand } from './commands/secrets'; import { shamirCommand } from './commands/shamir'; @@ -29,6 +30,7 @@ Commands: secrets Team secrets files (.env generation, recipients, rekeying) vault The local encrypted vault, shared with the desktop app account Constructive accounts and API keys, stored in the vault + passkey Passkeys held in the vault; dcrypt signs for the site keychain Store named secrets locally, always encrypted shamir Split and recombine a secret into authenticated shares salt Two-layer encryption: data under a salt, salt under your passphrase @@ -57,6 +59,7 @@ export const createCommandMap = (): Record => ({ secrets: secretsCommand, vault: vaultCommand, account: accountCommand, + passkey: passkeyCommand, keychain: keychainCommand, shamir: shamirCommand, salt: saltCommand, diff --git a/packages/cli/src/commands/passkey.ts b/packages/cli/src/commands/passkey.ts new file mode 100644 index 0000000..60fe5de --- /dev/null +++ b/packages/cli/src/commands/passkey.ts @@ -0,0 +1,168 @@ +import { Vault } from '@decryption/vault'; +import { PasskeyRecord, PasskeyStore } from '@decryption/webauthn'; +import { Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; + +import { runSubcommand, takeFirst } from '../utils/dispatch'; +import { CliError, EXIT } from '../utils/errors'; +import { emit, readStdin } from '../utils/io'; +import { openVault } from './vault'; + +export const passkeyUsage = ` +Passkey Command: + + dcrypt passkey [OPTIONS] + + Passkeys held in the vault. dcrypt is the authenticator: it makes the key, + keeps the private half encrypted, and signs the challenges a site sends. A + passkey signs only for the site it was made for, so it cannot be phished. + +Subcommands: + list [site] List stored passkeys, optionally for one site + register Create a passkey for a site and print the registration + assert Sign a sign-in challenge with the site's passkey + forget Delete a passkey from the vault + +Options: + --challenge The challenge the site issued (or --challenge-stdin) + --challenge-stdin Read the challenge from stdin + --origin Origin to sign, defaults to https:// + --user Account name to register (default: the site's account) + --credential Which passkey, when a site has more than one + --json Machine-readable output — what a relying party expects + --passphrase-file

Read the master password from a file + --help, -h Show this help message + +The response is printed as the JSON a WebAuthn relying party expects, so it can +be piped straight into one: + + dcrypt passkey register auth.example.com --user dev@example.com \\ + --challenge "$(cnc webauthn begin-registration)" --json | cnc webauthn finish +`; + +const withPasskeys = async ( + argv: ParsedArgs, + prompter: Inquirerer, + run: (passkeys: PasskeyStore, vault: Vault) => Promise +): Promise => { + const vault = await openVault(argv, prompter); + try { + await run(new PasskeyStore(vault), vault); + } finally { + await vault.lock(); + } +}; + +/** + * The challenge is a public nonce, so unlike a password it is fine in argv — + * but it is required: signing a challenge the caller invented proves nothing. + */ +const resolveChallenge = (argv: ParsedArgs): string => { + if (argv['challenge-stdin'] || argv.challengeStdin) { + return readStdin().trim(); + } + const challenge = argv.challenge; + if (typeof challenge !== 'string' || !challenge.length) { + throw new CliError('a --challenge from the site is required'); + } + return challenge; +}; + +const resolveOrigin = (argv: ParsedArgs, rpId: string): string => + typeof argv.origin === 'string' && argv.origin.length ? argv.origin : `https://${rpId}`; + +const describe = (record: PasskeyRecord): string => + `${record.userName.padEnd(28)} ${record.rpId.padEnd(28)} used ${record.signCount}×`; + +const find = async ( + passkeys: PasskeyStore, + site: string, + credentialId?: string +): Promise => { + const matches = await passkeys.list(site); + if (!matches.length) { + throw new CliError(`no passkey for "${site}" in the vault`, EXIT.notFound); + } + if (!credentialId) { + if (matches.length > 1) { + throw new CliError( + `${matches.length} passkeys for "${site}" — name one with --credential ` + ); + } + return matches[0]; + } + const match = matches.find((record) => record.credentialId === credentialId); + if (!match) throw new CliError(`no passkey ${credentialId} for "${site}"`, EXIT.notFound); + return match; +}; + +const list = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + await withPasskeys(newArgv, prompter, async (passkeys) => { + const all = await passkeys.list(first); + emit(newArgv, all, () => all.map(describe).join('\n') || '(no passkeys)'); + }); +}; + +const register = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + if (!first) throw new CliError('a site is required, e.g. auth.example.com'); + const challenge = resolveChallenge(newArgv); + const userName = typeof newArgv.user === 'string' ? newArgv.user : first; + + await withPasskeys(newArgv, prompter, async (passkeys) => { + const { record, response } = await passkeys.register({ + rpId: first, + origin: resolveOrigin(newArgv, first), + challenge, + userName, + }); + emit( + newArgv, + response, + () => `registered ${record.userName} at ${record.rpId} (${record.credentialId})` + ); + }); +}; + +const assert = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + if (!first) throw new CliError('a site is required, e.g. auth.example.com'); + const challenge = resolveChallenge(newArgv); + const credential = + typeof newArgv.credential === 'string' ? newArgv.credential : undefined; + + await withPasskeys(newArgv, prompter, async (passkeys) => { + const record = await find(passkeys, first, credential); + const response = await passkeys.assert(record.itemId, { + origin: resolveOrigin(newArgv, first), + challenge, + }); + emit(newArgv, response, () => `signed ${first}'s challenge as ${record.userName}`); + }); +}; + +const forget = async (argv: ParsedArgs, prompter: Inquirerer): Promise => { + const { first, newArgv } = takeFirst(argv); + if (!first) throw new CliError('a site is required'); + const credential = + typeof newArgv.credential === 'string' ? newArgv.credential : undefined; + + await withPasskeys(newArgv, prompter, async (passkeys) => { + const record = await find(passkeys, first, credential); + await passkeys.forget(record.itemId); + emit(newArgv, { credentialId: record.credentialId }, () => + `forgot ${record.userName}'s passkey for ${record.rpId}` + ); + }); +}; + +export const passkeyCommand = async ( + argv: ParsedArgs, + prompter: Inquirerer +): Promise => + runSubcommand(argv, prompter, { + name: 'passkey', + usage: passkeyUsage, + handlers: { list, register, assert, forget }, + }); diff --git a/packages/vault/src/types.ts b/packages/vault/src/types.ts index fadbadd..da9323a 100644 --- a/packages/vault/src/types.ts +++ b/packages/vault/src/types.ts @@ -7,7 +7,8 @@ export type ItemKind = | 'totp' | 'ssh_key' | 'account' - | 'api_key'; + | 'api_key' + | 'passkey'; export type FieldPurpose = | 'username' diff --git a/packages/webauthn/README.md b/packages/webauthn/README.md new file mode 100644 index 0000000..8dce790 --- /dev/null +++ b/packages/webauthn/README.md @@ -0,0 +1,77 @@ +# @decryption/webauthn + +

+ +

+ +

+ + + + +

+ +A software WebAuthn authenticator. A passkey is not a stored password — it is a P-256 keypair whose +private half never leaves the machine, and whose signature covers the site that asked for it. This +package makes those keys, signs the challenges a relying party issues, and keeps the private half in +the encrypted [dcrypt vault](https://www.npmjs.com/package/@decryption/vault). + +## Installation + +```bash +npm install @decryption/webauthn +``` + +## Usage + +```typescript +import { PasskeyStore } from '@decryption/webauthn'; +import { Vault, defaultModulePath } from '@decryption/vault'; + +const vault = await Vault.open({ file, passphrase, modulePath: defaultModulePath() }); +const passkeys = new PasskeyStore(vault); + +// registration: the challenge comes from the relying party +const { record, response } = await passkeys.register({ + rpId: 'auth.example.com', + origin: 'https://auth.example.com', + challenge, // base64url, from webauthn_begin_registration + userName: 'dev@example.com', +}); + +// sign-in: sign the challenge the site issued, and nothing else +const assertion = await passkeys.assert(record.itemId, { origin, challenge }); +``` + +`response` and `assertion` are shaped exactly as `navigator.credentials.create()` and `.get()` +resolve, so a relying party — `@simplewebauthn/server`, or Constructive's `auth:passkey` +procedures behind one — verifies them without knowing dcrypt exists. + +Without a vault, `createPasskey` and `assertPasskey` do the same work in memory and hand back the +key for the caller to store. + +## What is stored + +A passkey is a `passkey` vault item. Only the private key is concealed: a site name and a sign count +are not secrets, and leaving them readable is what lets a list render without decrypting anything. + +| Field | Purpose | +| --------------- | ------------------------------------------------------------- | +| `rp_id` | The site the key signs for, and no other | +| `credential_id` | What the site calls this key | +| `user_handle` | Opaque user id, so a site can sign you in without a username | +| `user_name` | The account it belongs to | +| `sign_count` | Advanced and persisted on every assertion | +| `private_key`\* | The 32-byte P-256 scalar | + +\* concealed. Because it is a vault item, a passkey is covered by the master passphrase, by lock and +by backup and restore — so unlike a hardware key it survives a lost laptop. + +## What it does not claim + +Attestation is `none` with an all-zero AAGUID: this is software, and it says so. A relying party that +requires hardware attestation should reject it, which is the correct outcome. + +## License + +MIT diff --git a/packages/webauthn/__tests__/authenticator.test.ts b/packages/webauthn/__tests__/authenticator.test.ts new file mode 100644 index 0000000..aabd244 --- /dev/null +++ b/packages/webauthn/__tests__/authenticator.test.ts @@ -0,0 +1,153 @@ +import { base64urlnopad } from '@decryption/base'; +import { p256 } from '@decryption/curves/nist'; +import { sha256 } from '@decryption/hashes/sha2'; + +import { + assertPasskey, + coseKey, + createPasskey, + ES256, + verifyAssertion, +} from '../src/authenticator'; +import { decode, encode } from '../src/cbor'; + +const RP = { rpId: 'auth.example.com', origin: 'https://auth.example.com' }; +const challenge = base64urlnopad.encode(Uint8Array.from({ length: 32 }, (_, i) => i)); + +const register = () => + createPasskey({ ...RP, challenge, userName: 'ci@example.com' }); + +/** What a relying party pulls out of the attestation object. */ +const attested = (attestationObject: string) => { + const object = decode(base64urlnopad.decode(attestationObject)) as Map; + const authData = object.get('authData') as Uint8Array; + const idLength = (authData[53] << 8) | authData[54]; + return { + fmt: object.get('fmt'), + rpIdHash: authData.slice(0, 32), + flags: authData[32], + signCount: new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0), + credentialId: authData.slice(55, 55 + idLength), + coseKey: authData.slice(55 + idLength), + }; +}; + +describe('createPasskey', () => { + it('binds the credential to the site, and to nothing else', () => { + const { passkey, response } = register(); + const data = attested(response.response.attestationObject); + + expect(Buffer.from(data.rpIdHash)).toEqual( + Buffer.from(sha256(new TextEncoder().encode(RP.rpId))) + ); + expect(base64urlnopad.encode(data.credentialId)).toBe(passkey.credentialId); + expect(data.signCount).toBe(0); + }); + + it('attests to nothing, because a software authenticator has nothing to attest', () => { + const { response } = register(); + expect(attested(response.response.attestationObject).fmt).toBe('none'); + }); + + it('reports the key as present, verified and backed up', () => { + const { flags } = attested(register().response.response.attestationObject); + expect(flags & 0x01).toBeTruthy(); // user present + expect(flags & 0x04).toBeTruthy(); // user verified — the vault was unlocked + expect(flags & 0x08).toBeTruthy(); // backup eligible: the vault is a file + expect(flags & 0x40).toBeTruthy(); // attested credential data follows + }); + + it('publishes the public half as a canonical ES256 COSE key', () => { + const { passkey, response } = register(); + const key = decode( + attested(response.response.attestationObject).coseKey + ) as Map; + + expect(key.get(1)).toBe(2); // EC2 + expect(key.get(3)).toBe(ES256); + expect(key.get(-1)).toBe(1); // P-256 + + const publicKey = p256.getPublicKey(base64urlnopad.decode(passkey.privateKey), false); + expect(Buffer.from(key.get(-2) as Uint8Array)).toEqual(Buffer.from(publicKey.slice(1, 33))); + // and re-encoding gives back the same bytes, so a relying party comparing + // the stored key to a re-derived one agrees + expect(Buffer.from(encode(key))).toEqual(Buffer.from(coseKey(publicKey))); + }); + + it('never repeats a credential id or a key', () => { + const first = register().passkey; + const second = register().passkey; + expect(first.credentialId).not.toBe(second.credentialId); + expect(first.privateKey).not.toBe(second.privateKey); + }); +}); + +describe('assertPasskey', () => { + const signIn = () => { + const { passkey } = register(); + const publicKey = p256.getPublicKey(base64urlnopad.decode(passkey.privateKey), false); + const assertion = assertPasskey(passkey, { ...RP, challenge }); + return { passkey, publicKey, assertion }; + }; + + it('produces a signature the site can verify', () => { + const { publicKey, assertion } = signIn(); + expect( + verifyAssertion( + publicKey, + base64urlnopad.decode(assertion.response.response.authenticatorData), + base64urlnopad.decode(assertion.response.response.clientDataJSON), + base64urlnopad.decode(assertion.response.response.signature) + ) + ).toBe(true); + }); + + it('signs the origin, so a signature cannot be replayed at another site', () => { + const { publicKey, assertion } = signIn(); + const clientDataJSON = JSON.parse( + new TextDecoder().decode(base64urlnopad.decode(assertion.response.response.clientDataJSON)) + ) as { type: string; challenge: string; origin: string }; + + expect(clientDataJSON).toMatchObject({ + type: 'webauthn.get', + challenge, + origin: RP.origin, + }); + + const phished = new TextEncoder().encode( + JSON.stringify({ ...clientDataJSON, origin: 'https://evil.example.com' }) + ); + expect( + verifyAssertion( + publicKey, + base64urlnopad.decode(assertion.response.response.authenticatorData), + phished, + base64urlnopad.decode(assertion.response.response.signature) + ) + ).toBe(false); + }); + + it('advances the sign count, so a cloned key gives itself away', () => { + const { passkey } = register(); + const first = assertPasskey(passkey, { ...RP, challenge }); + const second = assertPasskey(first.passkey, { ...RP, challenge }); + + expect(first.passkey.signCount).toBe(1); + expect(second.passkey.signCount).toBe(2); + // and the count is in the signed bytes, not just in the returned object + const authData = base64urlnopad.decode(second.response.response.authenticatorData); + expect(new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0)).toBe(2); + }); + + it('carries the user handle, so the site can sign in without a username', () => { + const { passkey, assertion } = signIn(); + expect(assertion.response.response.userHandle).toBe(passkey.userHandle); + }); + + it('does not carry attested credential data', () => { + const { assertion } = signIn(); + const authData = base64urlnopad.decode(assertion.response.response.authenticatorData); + expect(authData.length).toBe(37); + expect(authData[32] & 0x40).toBe(0); + }); +}); diff --git a/packages/webauthn/__tests__/cbor.test.ts b/packages/webauthn/__tests__/cbor.test.ts new file mode 100644 index 0000000..8923f39 --- /dev/null +++ b/packages/webauthn/__tests__/cbor.test.ts @@ -0,0 +1,44 @@ +import { decode, encode } from '../src/cbor'; + +const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex'); + +describe('cbor', () => { + it('encodes the RFC 8949 examples', () => { + expect(hex(encode(0))).toBe('00'); + expect(hex(encode(23))).toBe('17'); + expect(hex(encode(24))).toBe('1818'); + expect(hex(encode(1000))).toBe('1903e8'); + expect(hex(encode(1000000))).toBe('1a000f4240'); + expect(hex(encode(-1))).toBe('20'); + expect(hex(encode(-500))).toBe('3901f3'); + expect(hex(encode('a'))).toBe('6161'); + expect(hex(encode(Uint8Array.from([1, 2, 3, 4])))).toBe('4401020304'); + expect(hex(encode([1, 2, 3]))).toBe('83010203'); + }); + + it('orders map keys canonically, as CTAP2 requires', () => { + const out = encode( + new Map([ + [-3, 3], + [1, 1], + [-1, 1], + [3, -7], + ]) + ); + // 01, 03, 20 (-1), 22 (-3): shorter first, then bytewise + expect(hex(out)).toBe('a40101' + '0326' + '2001' + '2203'); + }); + + it('round-trips what an authenticator emits', () => { + const value = new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', Uint8Array.from({ length: 300 }, (_, i) => i % 256)], + ]); + expect(decode(encode(value as never))).toEqual(value); + }); + + it('refuses what WebAuthn never contains rather than guessing', () => { + expect(() => encode(1.5)).toThrow(/integers/); + }); +}); diff --git a/packages/webauthn/__tests__/store.test.ts b/packages/webauthn/__tests__/store.test.ts new file mode 100644 index 0000000..5cd0c6e --- /dev/null +++ b/packages/webauthn/__tests__/store.test.ts @@ -0,0 +1,121 @@ +import { base64urlnopad } from '@decryption/base'; +import { p256 } from '@decryption/curves/nist'; +import { Vault } from '@decryption/vault'; +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { verifyAssertion } from '../src/authenticator'; +import { PasskeyError, PasskeyStore } from '../src/store'; + +jest.setTimeout(120000); + +const MODULE_PATH = path.resolve(__dirname, '../../../pgpm-modules/dcrypt-vault'); +const FAST = { t: 1, m: 8192, p: 1 }; +const PASSPHRASE = 'a rather long master passphrase'; +const RP = { rpId: 'auth.example.com', origin: 'https://auth.example.com' }; +const challenge = base64urlnopad.encode(Uint8Array.from({ length: 32 }, (_, i) => i)); + +let dir: string; +let vault: Vault; +let passkeys: PasskeyStore; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcrypt-passkeys-')); + vault = await Vault.open({ + file: path.join(dir, 'vault.dcrypt'), + passphrase: PASSPHRASE, + modulePath: MODULE_PATH, + kdf: FAST, + }); + passkeys = new PasskeyStore(vault); +}); + +afterAll(async () => { + await vault.discard(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +beforeEach(async () => { + for (const item of await vault.listItems({ kind: 'passkey' })) { + await vault.deleteItemForever(item.id); + } +}); + +const register = () => passkeys.register({ ...RP, challenge, userName: 'ci@example.com' }); + +describe('PasskeyStore', () => { + it('keeps the private key concealed, and everything else readable', async () => { + const { record } = await register(); + const fields = await vault.listFields(record.itemId); + const concealed = fields.filter((field) => field.concealed).map((field) => field.name); + + expect(concealed).toEqual(['private_key']); + expect(fields.map((field) => field.name).sort()).toEqual([ + 'credential_id', + 'private_key', + 'rp_id', + 'sign_count', + 'user_handle', + 'user_name', + ]); + }); + + it('signs with the key it stored', async () => { + const { record, response } = await register(); + const publicKey = p256.getPublicKey( + base64urlnopad.decode(await vault.revealField(record.itemId, 'private_key')), + false + ); + + const assertion = await passkeys.assert(record.itemId, { ...RP, challenge }); + expect(assertion.id).toBe(response.id); + expect( + verifyAssertion( + publicKey, + base64urlnopad.decode(assertion.response.authenticatorData), + base64urlnopad.decode(assertion.response.clientDataJSON), + base64urlnopad.decode(assertion.response.signature) + ) + ).toBe(true); + }); + + it('persists the sign count, so it advances across a lock', async () => { + const { record } = await register(); + await passkeys.assert(record.itemId, { ...RP, challenge }); + await passkeys.assert(record.itemId, { ...RP, challenge }); + + const [stored] = await passkeys.list(); + expect(stored.signCount).toBe(2); + + const authData = base64urlnopad.decode( + (await passkeys.assert(record.itemId, { ...RP, challenge })).response.authenticatorData + ); + expect(new DataView(authData.buffer, authData.byteOffset + 33, 4).getUint32(0)).toBe(3); + }); + + it('lists the keys for one site', async () => { + await register(); + await passkeys.register({ + rpId: 'other.example.com', + origin: 'https://other.example.com', + challenge, + userName: 'ci@example.com', + }); + + expect(await passkeys.list(RP.rpId)).toHaveLength(1); + expect(await passkeys.list()).toHaveLength(2); + }); + + it('refuses to sign with something that is not a passkey', async () => { + const note = await vault.createItem('note', 'not a passkey'); + await expect(passkeys.assert(note.id, { ...RP, challenge })).rejects.toThrow(PasskeyError); + }); + + it('forgets a key entirely', async () => { + const { record } = await register(); + await passkeys.forget(record.itemId); + expect(await passkeys.list()).toHaveLength(0); + expect(await vault.getItem(record.itemId)).toBeNull(); + }); +}); diff --git a/packages/webauthn/jest.config.js b/packages/webauthn/jest.config.js new file mode 100644 index 0000000..f4c0ce0 --- /dev/null +++ b/packages/webauthn/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'], +}; diff --git a/packages/webauthn/package.json b/packages/webauthn/package.json new file mode 100644 index 0000000..e166d54 --- /dev/null +++ b/packages/webauthn/package.json @@ -0,0 +1,48 @@ +{ + "name": "@decryption/webauthn", + "version": "0.1.0", + "description": "A software WebAuthn authenticator: passkeys whose private keys live in the dcrypt vault", + "author": "Constructive ", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "homepage": "https://github.com/constructive-io/decryption", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/decryption" + }, + "bugs": { + "url": "https://github.com/constructive-io/decryption/issues" + }, + "scripts": { + "copy": "makage assets", + "clean": "makage clean", + "prepublishOnly": "npm run build", + "build": "makage build", + "lint": "eslint . --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "keywords": [ + "webauthn", + "passkey", + "authenticator", + "fido2", + "vault", + "local-first" + ], + "dependencies": { + "@decryption/base": "workspace:*", + "@decryption/curves": "workspace:*", + "@decryption/hashes": "workspace:*", + "@decryption/vault": "workspace:*" + }, + "devDependencies": { + "makage": "0.3.0" + } +} diff --git a/packages/webauthn/src/authenticator.ts b/packages/webauthn/src/authenticator.ts new file mode 100644 index 0000000..0f4dfcf --- /dev/null +++ b/packages/webauthn/src/authenticator.ts @@ -0,0 +1,195 @@ +import { base64urlnopad } from '@decryption/base'; +import { p256 } from '@decryption/curves/nist'; +import { sha256 } from '@decryption/hashes/sha2'; + +import { CborValue, encode } from './cbor'; +import type { + Assertion, + AssertionRequest, + Passkey, + Registration, + RegistrationRequest, +} from './types'; + +/** ES256: ECDSA with P-256 and SHA-256, the algorithm every site accepts. */ +export const ES256 = -7; + +/** + * Authenticators identify their make and model with an AAGUID. A software + * authenticator is required to report all-zero, which also says honestly that + * this key is not shielded by tamper-resistant hardware. + */ +const AAGUID = new Uint8Array(16); + +const FLAG = { + userPresent: 0x01, + userVerified: 0x04, + /** The key is one that can be backed up — the vault is a file you can copy. */ + backupEligible: 0x08, + backedUp: 0x10, + attestedCredentialData: 0x40, +} as const; + +const concat = (...chunks: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0)); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; +}; + +const uint32 = (value: number): Uint8Array => + Uint8Array.from([(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]); + +/** The uncompressed point, as a COSE_Key — the form a relying party stores. */ +export const coseKey = (publicKey: Uint8Array): Uint8Array => + encode( + new Map([ + [1, 2], // kty: EC2 + [3, ES256], // alg + [-1, 1], // crv: P-256 + [-2, publicKey.slice(1, 33)], // x + [-3, publicKey.slice(33, 65)], // y + ]) + ); + +/** + * `authenticatorData`: which site, what the user did, how many times this key + * has signed — and on registration, the credential itself. + */ +const authenticatorData = ( + rpId: string, + signCount: number, + attested?: { credentialId: Uint8Array; publicKey: Uint8Array } +): Uint8Array => { + const flags = + FLAG.userPresent | + FLAG.userVerified | + FLAG.backupEligible | + FLAG.backedUp | + (attested ? FLAG.attestedCredentialData : 0); + const head = concat(sha256(new TextEncoder().encode(rpId)), Uint8Array.from([flags]), uint32(signCount)); + if (!attested) return head; + const { credentialId, publicKey } = attested; + return concat( + head, + AAGUID, + Uint8Array.from([(credentialId.length >> 8) & 0xff, credentialId.length & 0xff]), + credentialId, + coseKey(publicKey) + ); +}; + +/** + * What the browser would have shown the authenticator: the challenge it is + * signing, and the origin it is signing it for. The origin is in here rather + * than in the signature's own fields, which is what makes a passkey + * unphishable — a site cannot get a signature naming a different origin. + */ +const clientData = (type: string, challenge: string, origin: string): Uint8Array => + new TextEncoder().encode( + JSON.stringify({ type, challenge, origin, crossOrigin: false }) + ); + +/** ECDSA over `authenticatorData || sha256(clientDataJSON)`, DER as WebAuthn wants. */ +const sign = (privateKey: Uint8Array, authData: Uint8Array, clientDataJSON: Uint8Array): Uint8Array => + p256.Signature.fromBytes( + p256.sign(concat(authData, sha256(clientDataJSON)), privateKey) + ).toBytes('der'); + +/** + * Mint a passkey for a site. The private key never leaves the return value — + * the caller's job is to put it straight into the vault. + */ +export const createPasskey = (request: RegistrationRequest): Registration => { + const privateKey = p256.utils.randomSecretKey(); + const publicKey = p256.getPublicKey(privateKey, false); + const credentialId = crypto.getRandomValues(new Uint8Array(32)); + const userHandle = + request.userHandle ?? base64urlnopad.encode(crypto.getRandomValues(new Uint8Array(32))); + + const authData = authenticatorData(request.rpId, 0, { credentialId, publicKey }); + const clientDataJSON = clientData('webauthn.create', request.challenge, request.origin); + + // fmt 'none': a software authenticator attests to nothing about its hardware, + // and claiming otherwise is exactly the lie attestation exists to catch + const attestationObject = encode( + new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', authData], + ]) + ); + + const id = base64urlnopad.encode(credentialId); + return { + passkey: { + credentialId: id, + rpId: request.rpId, + privateKey: base64urlnopad.encode(privateKey), + userHandle, + userName: request.userName, + signCount: 0, + }, + response: { + id, + rawId: id, + type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + response: { + clientDataJSON: base64urlnopad.encode(clientDataJSON), + attestationObject: base64urlnopad.encode(attestationObject), + transports: ['internal', 'hybrid'], + publicKey: base64urlnopad.encode(coseKey(publicKey)), + publicKeyAlgorithm: ES256, + authenticatorData: base64urlnopad.encode(authData), + }, + }, + }; +}; + +/** + * Sign a site's challenge with a passkey. The returned passkey carries the + * incremented sign count, which the caller must persist: a site that sees the + * count fail to advance is entitled to conclude the key has been cloned. + */ +export const assertPasskey = (passkey: Passkey, request: AssertionRequest): Assertion => { + const signCount = passkey.signCount + 1; + const authData = authenticatorData(passkey.rpId, signCount); + const clientDataJSON = clientData('webauthn.get', request.challenge, request.origin); + const signature = sign(base64urlnopad.decode(passkey.privateKey), authData, clientDataJSON); + + return { + passkey: { ...passkey, signCount }, + response: { + id: passkey.credentialId, + rawId: passkey.credentialId, + type: 'public-key', + clientExtensionResults: {}, + authenticatorAttachment: 'platform', + response: { + clientDataJSON: base64urlnopad.encode(clientDataJSON), + authenticatorData: base64urlnopad.encode(authData), + signature: base64urlnopad.encode(signature), + userHandle: passkey.userHandle, + }, + }, + }; +}; + +/** + * Check an assertion's signature. This is only the cryptographic half of what a + * relying party does — it still owes the challenge, origin, RP id hash and sign + * count checks — but a round trip whose signature is never verified proves + * nothing, which is what the tests are for. + */ +export const verifyAssertion = ( + publicKey: Uint8Array, + authData: Uint8Array, + clientDataJSON: Uint8Array, + signature: Uint8Array +): boolean => + p256.verify(signature, concat(authData, sha256(clientDataJSON)), publicKey, { format: 'der' }); diff --git a/packages/webauthn/src/cbor.ts b/packages/webauthn/src/cbor.ts new file mode 100644 index 0000000..a3428a5 --- /dev/null +++ b/packages/webauthn/src/cbor.ts @@ -0,0 +1,142 @@ +/** + * Just enough CBOR for WebAuthn: canonical (CTAP2) encoding of the maps and + * byte strings an authenticator emits, and decoding of the same. A passkey + * only ever needs integers, byte strings, text, arrays and maps — no tags, no + * floats, no indefinite lengths — so a full CBOR library would be a dependency + * carrying far more than it is asked to do. + */ +export type CborValue = + | number + | string + | boolean + | null + | Uint8Array + | CborValue[] + | Map; + +const head = (major: number, length: number): Uint8Array => { + if (length < 24) return Uint8Array.from([(major << 5) | length]); + if (length < 0x100) return Uint8Array.from([(major << 5) | 24, length]); + if (length < 0x10000) { + return Uint8Array.from([(major << 5) | 25, length >> 8, length & 0xff]); + } + return Uint8Array.from([ + (major << 5) | 26, + (length >>> 24) & 0xff, + (length >>> 16) & 0xff, + (length >>> 8) & 0xff, + length & 0xff, + ]); +}; + +const concat = (chunks: Uint8Array[]): Uint8Array => { + const out = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0)); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; +}; + +/** + * Canonical ordering per CTAP2: shorter keys first, then bytewise. Relying + * parties re-encode the COSE key to compare it, so the order is not cosmetic. + */ +const canonical = (entries: [CborValue, CborValue][]): [CborValue, CborValue][] => + [...entries].sort(([a], [b]) => { + const left = encode(a); + const right = encode(b); + if (left.length !== right.length) return left.length - right.length; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; + }); + +export const encode = (value: CborValue): Uint8Array => { + if (value === null) return Uint8Array.from([0xf6]); + if (typeof value === 'boolean') return Uint8Array.from([value ? 0xf5 : 0xf4]); + if (typeof value === 'number') { + if (!Number.isInteger(value)) throw new Error('cbor: only integers are supported'); + return value < 0 ? head(1, -value - 1) : head(0, value); + } + if (value instanceof Uint8Array) return concat([head(2, value.length), value]); + if (typeof value === 'string') { + const bytes = new TextEncoder().encode(value); + return concat([head(3, bytes.length), bytes]); + } + if (Array.isArray(value)) { + return concat([head(4, value.length), ...value.map(encode)]); + } + const entries = canonical([...value.entries()]); + return concat([ + head(5, entries.length), + ...entries.map(([key, item]) => concat([encode(key), encode(item)])), + ]); +}; + +interface Cursor { + bytes: Uint8Array; + at: number; +} + +const readLength = (cursor: Cursor, extra: number): number => { + if (extra < 24) return extra; + const width = extra === 24 ? 1 : extra === 25 ? 2 : extra === 26 ? 4 : 0; + if (!width) throw new Error('cbor: unsupported length encoding'); + let length = 0; + for (let i = 0; i < width; i += 1) { + length = length * 256 + cursor.bytes[cursor.at + i]; + } + cursor.at += width; + return length; +}; + +const readValue = (cursor: Cursor): CborValue => { + const byte = cursor.bytes[cursor.at]; + cursor.at += 1; + const major = byte >> 5; + const extra = byte & 0x1f; + switch (major) { + case 0: + return readLength(cursor, extra); + case 1: + return -1 - readLength(cursor, extra); + case 2: { + const length = readLength(cursor, extra); + const slice = cursor.bytes.slice(cursor.at, cursor.at + length); + cursor.at += length; + return slice; + } + case 3: { + const length = readLength(cursor, extra); + const slice = cursor.bytes.slice(cursor.at, cursor.at + length); + cursor.at += length; + return new TextDecoder().decode(slice); + } + case 4: { + const length = readLength(cursor, extra); + return Array.from({ length }, () => readValue(cursor)); + } + case 5: { + const length = readLength(cursor, extra); + const map = new Map(); + for (let i = 0; i < length; i += 1) { + const key = readValue(cursor); + map.set(key, readValue(cursor)); + } + return map; + } + case 7: + if (extra === 20) return false; + if (extra === 21) return true; + if (extra === 22) return null; + throw new Error('cbor: unsupported simple value'); + default: + throw new Error(`cbor: unsupported major type ${major}`); + } +}; + +export const decode = (bytes: Uint8Array): CborValue => + readValue({ bytes, at: 0 }); diff --git a/packages/webauthn/src/index.ts b/packages/webauthn/src/index.ts new file mode 100644 index 0000000..7452315 --- /dev/null +++ b/packages/webauthn/src/index.ts @@ -0,0 +1,4 @@ +export * from './authenticator'; +export * from './cbor'; +export * from './store'; +export * from './types'; diff --git a/packages/webauthn/src/store.ts b/packages/webauthn/src/store.ts new file mode 100644 index 0000000..2e478b3 --- /dev/null +++ b/packages/webauthn/src/store.ts @@ -0,0 +1,135 @@ +import { Vault, VaultItem } from '@decryption/vault'; + +import { assertPasskey, createPasskey } from './authenticator'; +import type { + AssertionRequest, + AssertionResponse, + Passkey, + RegistrationRequest, + RegistrationResponse, +} from './types'; + +const FIELDS = { + rpId: 'rp_id', + credentialId: 'credential_id', + userHandle: 'user_handle', + userName: 'user_name', + signCount: 'sign_count', + privateKey: 'private_key', +} as const; + +/** A stored passkey, as the UI and CLI list it — no private key in sight. */ +export interface PasskeyRecord { + itemId: string; + rpId: string; + credentialId: string; + userName: string; + signCount: number; + createdAt: string; +} + +export class PasskeyError extends Error { + constructor(message: string) { + super(message); + this.name = 'PasskeyError'; + } +} + +/** + * Passkeys kept as vault items: dcrypt is the authenticator, and the vault is + * its secure element. The private key is the only concealed field — a site + * name and a sign count are not secrets, and leaving them readable is what + * lets the list render without decrypting anything. + * + * A passkey is therefore covered by the master passphrase, by lock, and by + * backup/restore, and unlike a hardware key it can be restored onto a new + * machine from a backup. + */ +export class PasskeyStore { + constructor(private readonly vault: Vault) {} + + /** + * Mint a passkey for a site and return the registration the relying party + * verifies. The key is written to the vault before the response is handed + * back, so a site can never end up holding a public key whose private half + * was lost. + */ + async register( + request: RegistrationRequest + ): Promise<{ record: PasskeyRecord; response: RegistrationResponse }> { + const { passkey, response } = createPasskey(request); + const item = await this.vault.createItem( + 'passkey', + `${request.userName} @ ${request.rpId}` + ); + await this.write(item.id, passkey); + return { record: await this.read(item), response }; + } + + async list(rpId?: string): Promise { + const items = await this.vault.listItems({ kind: 'passkey' }); + const records = await Promise.all(items.map((item) => this.read(item))); + return rpId ? records.filter((record) => record.rpId === rpId) : records; + } + + /** + * Sign a site's challenge, and persist the advanced sign count before + * returning: a site that later sees the count fail to advance concludes the + * key was cloned, so losing the write is worse than losing the assertion. + */ + async assert(itemId: string, request: AssertionRequest): Promise { + const passkey = await this.reveal(itemId); + const assertion = assertPasskey(passkey, request); + await this.vault.setField( + itemId, + FIELDS.signCount, + 'text', + String(assertion.passkey.signCount), + false + ); + return assertion.response; + } + + /** Delete the key. The site keeps its credential; it will simply never match. */ + async forget(itemId: string): Promise { + await this.vault.deleteItemForever(itemId); + } + + private async write(itemId: string, passkey: Passkey): Promise { + await this.vault.setField(itemId, FIELDS.rpId, 'url', passkey.rpId, false); + await this.vault.setField(itemId, FIELDS.credentialId, 'text', passkey.credentialId, false); + await this.vault.setField(itemId, FIELDS.userHandle, 'text', passkey.userHandle, false); + await this.vault.setField(itemId, FIELDS.userName, 'username', passkey.userName, false); + await this.vault.setField(itemId, FIELDS.signCount, 'text', String(passkey.signCount), false); + await this.vault.setField(itemId, FIELDS.privateKey, 'private_key', passkey.privateKey); + } + + /** The whole passkey, private key and all — only used to sign. */ + private async reveal(itemId: string): Promise { + const item = await this.vault.getItem(itemId); + if (!item || item.kind !== 'passkey') { + throw new PasskeyError(`item ${itemId} is not a passkey`); + } + const record = await this.read(item); + return { + credentialId: record.credentialId, + rpId: record.rpId, + privateKey: await this.vault.revealField(itemId, FIELDS.privateKey), + userHandle: await this.vault.revealField(itemId, FIELDS.userHandle), + userName: record.userName, + signCount: record.signCount, + }; + } + + private async read(item: VaultItem): Promise { + const value = async (name: string) => this.vault.revealField(item.id, name); + return { + itemId: item.id, + rpId: await value(FIELDS.rpId), + credentialId: await value(FIELDS.credentialId), + userName: await value(FIELDS.userName), + signCount: Number(await value(FIELDS.signCount)), + createdAt: item.createdAt, + }; + } +} diff --git a/packages/webauthn/src/types.ts b/packages/webauthn/src/types.ts new file mode 100644 index 0000000..af41730 --- /dev/null +++ b/packages/webauthn/src/types.ts @@ -0,0 +1,79 @@ +/** A passkey as dcrypt holds it: a P-256 key and what the site needs to find it. */ +export interface Passkey { + /** base64url, as a relying party sends and receives it. */ + credentialId: string; + /** The site the key belongs to — a passkey signs for nothing else. */ + rpId: string; + /** base64url-encoded 32-byte P-256 scalar. Only this is a secret. */ + privateKey: string; + /** The user handle the site knows, base64url. Enables usernameless sign-in. */ + userHandle: string; + userName: string; + /** How many assertions this key has made; a site rejects a count going backwards. */ + signCount: number; +} + +/** What a site asks for when a passkey is created. */ +export interface RegistrationRequest { + rpId: string; + origin: string; + /** base64url, from `webauthn_begin_registration`. */ + challenge: string; + userName: string; + /** base64url; generated when absent. */ + userHandle?: string; +} + +/** What a site asks for when a passkey is used. */ +export interface AssertionRequest { + origin: string; + /** base64url, from `webauthn_begin_sign_in`. */ + challenge: string; +} + +/** + * The shape `navigator.credentials.create()` resolves to, as a relying party + * such as `@simplewebauthn/server` expects to receive it over the wire. + */ +export interface RegistrationResponse { + id: string; + rawId: string; + type: 'public-key'; + clientExtensionResults: Record; + authenticatorAttachment: 'platform'; + response: { + clientDataJSON: string; + attestationObject: string; + transports: string[]; + publicKey: string; + publicKeyAlgorithm: number; + authenticatorData: string; + }; +} + +/** The shape `navigator.credentials.get()` resolves to. */ +export interface AssertionResponse { + id: string; + rawId: string; + type: 'public-key'; + clientExtensionResults: Record; + authenticatorAttachment: 'platform'; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; + }; +} + +/** A new passkey, plus the one-off response that registers it with the site. */ +export interface Registration { + passkey: Passkey; + response: RegistrationResponse; +} + +/** An assertion, and the passkey with its sign count moved on. */ +export interface Assertion { + passkey: Passkey; + response: AssertionResponse; +} diff --git a/packages/webauthn/tsconfig.esm.json b/packages/webauthn/tsconfig.esm.json new file mode 100644 index 0000000..800d750 --- /dev/null +++ b/packages/webauthn/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "es2022", + "rootDir": "src/", + "declaration": false + } +} diff --git a/packages/webauthn/tsconfig.json b/packages/webauthn/tsconfig.json new file mode 100644 index 0000000..ef02482 --- /dev/null +++ b/packages/webauthn/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.spec.*", + "**/*.test.*" + ] +} diff --git a/pgpm-modules/dcrypt-vault/deploy/schemas/dcrypt_vault/passkey_types.sql b/pgpm-modules/dcrypt-vault/deploy/schemas/dcrypt_vault/passkey_types.sql new file mode 100644 index 0000000..2b65b31 --- /dev/null +++ b/pgpm-modules/dcrypt-vault/deploy/schemas/dcrypt_vault/passkey_types.sql @@ -0,0 +1,12 @@ +-- Deploy schemas/dcrypt_vault/passkey_types to pg +-- requires: schemas/dcrypt_vault/account_types + +-- A passkey is a P-256 private key held for one site. The public half, the +-- credential id and the sign count are not secret and live in plain fields; +-- only the key itself is concealed. + +BEGIN; + +ALTER TYPE dcrypt_vault.item_kind ADD VALUE 'passkey'; + +COMMIT; diff --git a/pgpm-modules/dcrypt-vault/pgpm.plan b/pgpm-modules/dcrypt-vault/pgpm.plan index c660b84..73df4e5 100644 --- a/pgpm-modules/dcrypt-vault/pgpm.plan +++ b/pgpm-modules/dcrypt-vault/pgpm.plan @@ -20,3 +20,4 @@ schemas/dcrypt_vault/procedures/reveal_field [schemas/dcrypt_vault/procedures/se schemas/dcrypt_vault/procedures/totp_code [schemas/dcrypt_vault/procedures/reveal_field pgpm-totp:schemas/totp/procedures/generate_totp] 2026-07-26T00:00:14Z Dan Lynch # add schemas/dcrypt_vault/procedures/totp_code schemas/dcrypt_vault/procedures/search_items [schemas/dcrypt_vault/tables/urls schemas/dcrypt_vault/tables/item_tags] 2026-07-26T00:00:15Z Dan Lynch # add schemas/dcrypt_vault/procedures/search_items schemas/dcrypt_vault/account_types [schemas/dcrypt_vault/types] 2026-07-26T00:00:16Z Dan Lynch # add schemas/dcrypt_vault/account_types +schemas/dcrypt_vault/passkey_types [schemas/dcrypt_vault/account_types] 2026-07-26T00:00:17Z Dan Lynch # add schemas/dcrypt_vault/passkey_types diff --git a/pgpm-modules/dcrypt-vault/revert/schemas/dcrypt_vault/passkey_types.sql b/pgpm-modules/dcrypt-vault/revert/schemas/dcrypt_vault/passkey_types.sql new file mode 100644 index 0000000..59b7800 --- /dev/null +++ b/pgpm-modules/dcrypt-vault/revert/schemas/dcrypt_vault/passkey_types.sql @@ -0,0 +1,29 @@ +-- Revert schemas/dcrypt_vault/passkey_types from pg + +-- Postgres cannot drop an enum value, so the type is rebuilt without it. The +-- cast fails if any row still carries one, which is the right answer: the data +-- has to go before the value can. + +BEGIN; + +ALTER TYPE dcrypt_vault.item_kind RENAME TO item_kind_passkeys; + +CREATE TYPE dcrypt_vault.item_kind AS ENUM ( + 'login', + 'note', + 'card', + 'identity', + 'wallet', + 'totp', + 'ssh_key', + 'account', + 'api_key' +); + +ALTER TABLE dcrypt_vault.items + ALTER COLUMN kind TYPE dcrypt_vault.item_kind + USING kind::text::dcrypt_vault.item_kind; + +DROP TYPE dcrypt_vault.item_kind_passkeys; + +COMMIT; diff --git a/pgpm-modules/dcrypt-vault/verify/schemas/dcrypt_vault/passkey_types.sql b/pgpm-modules/dcrypt-vault/verify/schemas/dcrypt_vault/passkey_types.sql new file mode 100644 index 0000000..b225ac5 --- /dev/null +++ b/pgpm-modules/dcrypt-vault/verify/schemas/dcrypt_vault/passkey_types.sql @@ -0,0 +1,7 @@ +-- Verify schemas/dcrypt_vault/passkey_types on pg + +BEGIN; + +SELECT 'passkey'::dcrypt_vault.item_kind; + +ROLLBACK; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 431ef5e..178fbf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: '@decryption/wallet': specifier: workspace:* version: link:../wallet/dist + '@decryption/webauthn': + specifier: workspace:* + version: link:../webauthn/dist appstash: specifier: ^0.7.0 version: 0.7.0 @@ -478,6 +481,26 @@ importers: version: 0.3.0 publishDirectory: dist + packages/webauthn: + dependencies: + '@decryption/base': + specifier: workspace:* + version: link:../base/dist + '@decryption/curves': + specifier: workspace:* + version: link:../curves/dist + '@decryption/hashes': + specifier: workspace:* + version: link:../hashes/dist + '@decryption/vault': + specifier: workspace:* + version: link:../vault/dist + devDependencies: + makage: + specifier: 0.3.0 + version: 0.3.0 + publishDirectory: dist + pgpm-modules/dcrypt-vault: dependencies: '@pgpm/base32':