Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions packages/cli/__tests__/passkey.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> => {
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 <subcommand>');
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"');
});
});
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -57,6 +59,7 @@ export const createCommandMap = (): Record<string, Handler> => ({
secrets: secretsCommand,
vault: vaultCommand,
account: accountCommand,
passkey: passkeyCommand,
keychain: keychainCommand,
shamir: shamirCommand,
salt: saltCommand,
Expand Down
168 changes: 168 additions & 0 deletions packages/cli/src/commands/passkey.ts
Original file line number Diff line number Diff line change
@@ -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 <subcommand> [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 <site> Create a passkey for a site and print the registration
assert <site> Sign a sign-in challenge with the site's passkey
forget <site> Delete a passkey from the vault

Options:
--challenge <b64url> The challenge the site issued (or --challenge-stdin)
--challenge-stdin Read the challenge from stdin
--origin <url> Origin to sign, defaults to https://<site>
--user <name> Account name to register (default: the site's account)
--credential <id> Which passkey, when a site has more than one
--json Machine-readable output — what a relying party expects
--passphrase-file <p> 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<void>
): Promise<void> => {
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<PasskeyRecord> => {
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 <id>`
);
}
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> =>
runSubcommand(argv, prompter, {
name: 'passkey',
usage: passkeyUsage,
handlers: { list, register, assert, forget },
});
3 changes: 2 additions & 1 deletion packages/vault/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export type ItemKind =
| 'totp'
| 'ssh_key'
| 'account'
| 'api_key';
| 'api_key'
| 'passkey';

export type FieldPurpose =
| 'username'
Expand Down
Loading
Loading