mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-09-11 19:46:27 +00:00
Add the LogicSRC OpenCreds specification (#140)
* Add the LogicSRC OpenCreds specification Leaving a password manager means writing every secret you own to disk in the clear, and losing whatever the spreadsheet had no column for. A CSV is plaintext by construction, lossy by omission, and carries no integrity: nothing in it says which rows were meant to be there, so a truncated import looks exactly like a complete one. The same gap showed up inside LogicSRC. `logicsrc credentials` moves .env secrets and SSH keys through end-to-end-encrypted team vaults, but it can only model a key/value pair. A card, a passport, a login with a TOTP seed, or an OAuth account with a refresh token are all things people already keep in a vault, and none of them are a key/value pair. OpenCreds defines three things: the item, the vault, and the database. - Six item types (login, card, identity, note, key, account) as one record with a type and a named field group, so everything the user typed lives in a single encrypted blob. Codes 1-4 match MarkSyncr's deployed vault and are not renumbered; compatibility is cheaper than elegance. - AES-256-GCM over that record with the item id bound as AAD. Without it, anyone with storage write access could move a low-value login's ciphertext into a high-value row and watch what the user does next. - A key hierarchy where the user key is random, not derived, so a password change re-wraps 32 bytes rather than re-encrypting a vault. The auth hash comes out of a different HKDF label than the wrap key, which is what lets it reach a server at all. - A portable .opencreds file, encrypted by default, whose header is the AAD over the payload -- so the manifest is authenticated by the same tag as the data and a truncated import fails rather than reporting success. The plaintext form exists because people move to products that read nothing else; it is opt-in, confirmed, 0600, and labelled "protected": false in its own header. Namespaces are carried as data, not fixed by the spec: labels are compiled into every ciphertext a vault has written, so editing one does not migrate a vault, it makes it undecryptable. MarkSyncr's deployed vault is conformant by declaring `marksyncr`. Ships: prd/0004, nine spec pages under docs/opencreds/, six JSON Schemas, the @logicsrc/opencreds reference implementation with CSV importers for five products, `logicsrc vault` and the standalone `opencreds` binary, and the spec page at logicsrc.com/opencreds. `vault` rather than `creds` because `creds` is already an alias of `logicsrc credentials`, and the two are different: one moves a pair between providers, the other stores a record. @logicsrc/validators now registers every schema by $id before compiling, so the database schema can $ref the item and manifest schemas rather than restating them. 120 tests, including CLI end-to-end coverage of the masking rules, exit codes, and the manifest-mismatch path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Make the OpenCreds conformance claim executable The conformance page described a fixture suite and an `opencreds conformance` command that did not exist. A specification that documents a conformance surface it cannot run is a specification nobody can hold to, including us. `opencreds conformance` now runs the requirement list as code -- one check per C-number, carrying its own id and level -- and emits the report shape the spec publishes. It exits 2 when a MUST does not pass, so it can gate CI directly. The reference implementation reports 29 passed, 0 failed, 1 skipped; the skip is C19, because key management for the team profile lives in @logicsrc/plugin-credential-sharing rather than in this package, and a skipped MAY does not affect conformance. Fixtures are generated (`--emit-fixtures <dir>`) rather than hand-written. A vector produced by an implementation and then verified by it is worth more than a JSON file someone typed: the typed file drifts silently when the format moves, and the generated one cannot. Fourteen files, including an invalid/ set every conforming reader must reject -- a wrong field group, a weak KDF, an unregistered namespace, a short payload and a tampered manifest. The CLI requirements stay with the end-to-end tests that drive the real binary through a child process; a command cannot meaningfully check its own exit codes, and a masked value that is only masked in the library is not masked. conformance.md and cli.md now describe what ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Add @logicsrc/opencreds to the lockfile `npm ci` refuses a lockfile that does not match package.json, and the new workspace package plus the CLI's dependency on it were never recorded: the worktree was bootstrapped by hardlinking node_modules rather than installing, so npm was never asked to update the lock. Adds the workspace link and the package entry. No dependency versions move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Register PRD 0004, and stop the fixtures looking like real secrets Two CI failures, both mine. `prd/README.md` is generated by `logicsrc prd index --write` and the scaffold test asserts it is current, so adding a PRD without regenerating it leaves the repo's own conformance check failing. Regenerated. The MCP test asserts the next free PRD id against the live prd/ directory — its comment says it advances with every PRD added — so it moves to 0005. ThreatCrush flagged three of the example strings: a PEM header in the item-model docs and in the conformance fixture, and an `sk_live_` prefixed token. All placeholders, none real, but the finding is the scanner working. A fixture only has to exercise the field, and a real-looking private key header or live-key prefix sitting in the tree trains both the scanner and the people reading its output to shrug at exactly the shape that matters. Replaced with obvious placeholders rather than suppressing the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1805d08e5
commit
80a36269bb
56 changed files with 8903 additions and 9 deletions
50
packages/opencreds/src/audit.ts
Normal file
50
packages/opencreds/src/audit.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Audit events.
|
||||
*
|
||||
* An event never contains a secret value. Where a value must be referenced it
|
||||
* is referenced by a salted, truncated fingerprint — an equality and integrity
|
||||
* marker, not secret storage. Item *names* are secret-adjacent too (a folder
|
||||
* list is a good description of someone's life), so an event carries the item
|
||||
* id and type rather than its name.
|
||||
*/
|
||||
|
||||
import { randomBytes, sha256, toBase64, utf8Encode, uuid } from "./primitives.js";
|
||||
import type { AuditAction, AuditEvent, ItemTypeName, Namespace, Profile } from "./types.js";
|
||||
|
||||
/**
|
||||
* A per-process fingerprint salt.
|
||||
*
|
||||
* Fresh each run, so fingerprints are comparable within one audit session and
|
||||
* not across machines. A fixed salt would turn the audit log into a dictionary
|
||||
* for the values it describes.
|
||||
*/
|
||||
const SALT = randomBytes(16);
|
||||
|
||||
export async function fingerprint(value: string): Promise<string> {
|
||||
const digest = await sha256(new Uint8Array([...SALT, ...utf8Encode(value)]));
|
||||
return toBase64(digest).slice(0, 16);
|
||||
}
|
||||
|
||||
export interface AuditInput {
|
||||
action: AuditAction;
|
||||
itemId?: string;
|
||||
itemType?: ItemTypeName;
|
||||
namespace?: Namespace;
|
||||
profile?: Profile;
|
||||
principal?: AuditEvent["principal"];
|
||||
fingerprint?: string;
|
||||
itemCount?: number;
|
||||
dryRun?: boolean;
|
||||
outcome?: AuditEvent["outcome"];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function auditEvent(input: AuditInput): AuditEvent {
|
||||
return {
|
||||
type: "opencreds.audit_event",
|
||||
id: uuid(),
|
||||
createdAt: new Date().toISOString(),
|
||||
outcome: "succeeded",
|
||||
...input,
|
||||
};
|
||||
}
|
||||
282
packages/opencreds/src/cli.test.ts
Normal file
282
packages/opencreds/src/cli.test.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/**
|
||||
* End-to-end CLI tests.
|
||||
*
|
||||
* The specification treats the CLI as a conformance surface — flags, output
|
||||
* shapes and exit codes — so these drive the real binary through a child
|
||||
* process rather than calling the functions underneath it. A masked value that
|
||||
* is only masked in the library is not masked.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url));
|
||||
const PASSWORD = "correct horse battery staple";
|
||||
const EXPORT_PASSPHRASE = "opencreds-fixture";
|
||||
|
||||
let home: string;
|
||||
let session: string;
|
||||
|
||||
interface RunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/** Run the CLI through tsx, so the test exercises the same source the build emits. */
|
||||
async function cli(args: string[], input?: string, env: Record<string, string> = {}): Promise<RunResult> {
|
||||
try {
|
||||
const child = execFileAsync("npx", ["tsx", CLI, "--home", home, ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
if (input !== undefined) {
|
||||
child.child.stdin?.end(input);
|
||||
}
|
||||
const { stdout, stderr } = await child;
|
||||
return { stdout, stderr, code: 0 };
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string; code?: number };
|
||||
return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", code: e.code ?? 1 };
|
||||
}
|
||||
}
|
||||
|
||||
function authed(args: string[], input?: string): Promise<RunResult> {
|
||||
return cli(args, input, { OPENCREDS_SESSION: session });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), "opencreds-cli-"));
|
||||
|
||||
const init = await cli(["init", "--password-stdin", "--iterations", "100000"], PASSWORD);
|
||||
expect(init.code, init.stderr).toBe(0);
|
||||
expect(init.stdout).toMatch(/Recovery key/);
|
||||
|
||||
const unlock = await cli(["unlock", "--password-stdin"], PASSWORD);
|
||||
expect(unlock.code, unlock.stderr).toBe(0);
|
||||
session = unlock.stdout.trim().replace(/^export OPENCREDS_SESSION="/, "").replace(/"$/, "");
|
||||
expect(session.length).toBeGreaterThan(20);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
if (home) rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the vault lifecycle", () => {
|
||||
it("refuses to re-init over an existing vault, with the refused exit code", async () => {
|
||||
// C30 — 4 is "needs a confirmation that was not given".
|
||||
const result = await cli(["init", "--password-stdin"], PASSWORD);
|
||||
expect(result.code).toBe(4);
|
||||
expect(result.stderr).toMatch(/already exists/);
|
||||
});
|
||||
|
||||
it("reports status while locked, with counts and no values", async () => {
|
||||
// C34.
|
||||
const result = await cli(["status", "--json"]);
|
||||
expect(result.code).toBe(0);
|
||||
const status = JSON.parse(result.stdout);
|
||||
expect(status.present).toBe(true);
|
||||
expect(status.unlocked).toBe(false);
|
||||
expect(status.namespace).toBe("opencreds");
|
||||
expect(status.profile).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("items", () => {
|
||||
it("adds one of every type", async () => {
|
||||
const added = [
|
||||
await authed(["add", "login", "--name", "GitHub", "--username", "anthony", "--password", "hunter2", "--url", "https://github.com"]),
|
||||
await authed(["add", "card", "--name", "Visa", "--number", "4242424242424242", "--code", "123"]),
|
||||
await authed(["add", "identity", "--name", "Me", "--first-name", "Anthony", "--ssn", "000-00-0000"]),
|
||||
await authed(["add", "note", "--name", "WiFi", "--notes", "on the router"]),
|
||||
await authed(["add", "key", "--name", "deploy", "--key-type", "ssh", "--private-key", "<private key body>", "--mode", "0600"]),
|
||||
await authed(["add", "account", "--name", "Stripe", "--provider", "stripe", "--access-token", "<access token>", "--scope", "charges:write"]),
|
||||
];
|
||||
for (const result of added) expect(result.code, result.stderr).toBe(0);
|
||||
|
||||
const status = JSON.parse((await cli(["status", "--json"])).stdout);
|
||||
expect(status.itemCount).toBe(6);
|
||||
expect(status.types).toEqual({ login: 1, card: 1, identity: 1, note: 1, key: 1, account: 1 });
|
||||
}, 60_000);
|
||||
|
||||
it("never prints a secret in list output, including --json", async () => {
|
||||
// C31, C32 — a pipeline is not an authorization.
|
||||
const plain = await authed(["list"]);
|
||||
expect(plain.stdout).toContain("GitHub");
|
||||
expect(plain.stdout).not.toContain("hunter2");
|
||||
|
||||
const json = await authed(["list", "--json"]);
|
||||
expect(json.stdout).not.toContain("hunter2");
|
||||
expect(json.stdout).not.toContain("4242424242424242");
|
||||
expect(json.stdout).not.toContain("<access token>");
|
||||
expect(json.stdout).not.toContain("000-00-0000");
|
||||
// Non-secret fields are still there, or the output would be useless.
|
||||
expect(json.stdout).toContain("anthony");
|
||||
});
|
||||
|
||||
it("masks a whole item on get, and reveals exactly one named field", async () => {
|
||||
const masked = await authed(["get", "GitHub"]);
|
||||
expect(masked.stdout).not.toContain("hunter2");
|
||||
expect(masked.stdout).toContain("anthony");
|
||||
|
||||
const revealed = await authed(["get", "GitHub", "--field", "login.password", "--reveal"]);
|
||||
expect(revealed.stdout.trim()).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("refuses --reveal without a field", async () => {
|
||||
const result = await authed(["get", "GitHub", "--reveal"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toMatch(/--reveal needs --field/);
|
||||
});
|
||||
|
||||
it("filters by type and searches by name", async () => {
|
||||
const logins = await authed(["list", "--type", "login"]);
|
||||
expect(logins.stdout).toContain("GitHub");
|
||||
expect(logins.stdout).not.toContain("Visa");
|
||||
|
||||
const search = await authed(["list", "--search", "vis"]);
|
||||
expect(search.stdout).toContain("Visa");
|
||||
expect(search.stdout).not.toContain("GitHub");
|
||||
});
|
||||
|
||||
it("rejects an unknown type with the usage exit code", async () => {
|
||||
const result = await authed(["list", "--type", "passport"]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toMatch(/Unknown type/);
|
||||
});
|
||||
|
||||
it("records the replaced password in history when one is edited", async () => {
|
||||
const edit = await authed(["edit", "login", "GitHub", "--password", "hunter3"]);
|
||||
expect(edit.code, edit.stderr).toBe(0);
|
||||
|
||||
const revealed = await authed(["get", "GitHub", "--field", "login.password", "--reveal"]);
|
||||
expect(revealed.stdout.trim()).toBe("hunter3");
|
||||
|
||||
const json = JSON.parse((await authed(["get", "GitHub"])).stdout);
|
||||
expect(json.history).toHaveLength(1);
|
||||
// Even in history, the old value is masked.
|
||||
expect(json.history[0].password).not.toBe("hunter2");
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("export and import", () => {
|
||||
it("exports an encrypted database that holds no plaintext secret", async () => {
|
||||
const out = join(home, "vault.opencreds");
|
||||
const result = await authed(["export", "--out", out, "--passphrase-stdin"], EXPORT_PASSPHRASE);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
|
||||
const raw = readFileSync(out, "utf8");
|
||||
expect(raw).not.toContain("hunter3");
|
||||
expect(raw).not.toContain("4242424242424242");
|
||||
const db = JSON.parse(raw);
|
||||
expect(db.protected).toBe(true);
|
||||
expect(db.manifest.itemCount).toBe(6);
|
||||
}, 60_000);
|
||||
|
||||
it("refuses a plaintext export without --yes", async () => {
|
||||
// C24, C30 — exit 4 is "refused".
|
||||
const result = await authed(["export", "--plaintext", "--out", join(home, "leak.json")]);
|
||||
expect(result.code).toBe(4);
|
||||
expect(result.stderr).toMatch(/needs --yes/);
|
||||
expect(result.stdout).toMatch(/cannot be un-leaked/);
|
||||
});
|
||||
|
||||
it("writes a plaintext export when told to, and labels it unprotected", async () => {
|
||||
const out = join(home, "plain.json");
|
||||
const result = await authed(["export", "--plaintext", "--yes", "--out", out]);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
const db = JSON.parse(readFileSync(out, "utf8"));
|
||||
expect(db.protected).toBe(false);
|
||||
expect(JSON.stringify(db)).toContain("hunter3");
|
||||
}, 30_000);
|
||||
|
||||
it("previews an import and writes nothing on --dry-run", async () => {
|
||||
// C33.
|
||||
const before = JSON.parse((await cli(["status", "--json"])).stdout).itemCount;
|
||||
const result = await authed(
|
||||
["import", join(home, "vault.opencreds"), "--dry-run", "--passphrase-stdin"],
|
||||
EXPORT_PASSPHRASE,
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toMatch(/Manifest {4}verified/);
|
||||
expect(result.stdout).toMatch(/Nothing written/);
|
||||
expect(JSON.parse((await cli(["status", "--json"])).stdout).itemCount).toBe(before);
|
||||
}, 60_000);
|
||||
|
||||
it("writes nothing when the manifest disagrees with the payload", async () => {
|
||||
// C23, C30 — exit 3 is a crypto failure, and nothing is imported.
|
||||
const tampered = join(home, "tampered.opencreds");
|
||||
const db = JSON.parse(readFileSync(join(home, "vault.opencreds"), "utf8"));
|
||||
db.manifest.itemCount = 99;
|
||||
writeFileSync(tampered, JSON.stringify(db));
|
||||
|
||||
const before = JSON.parse((await cli(["status", "--json"])).stdout).itemCount;
|
||||
const result = await authed(["import", tampered, "--passphrase-stdin"], EXPORT_PASSPHRASE);
|
||||
expect(result.code).toBe(3);
|
||||
expect(JSON.parse((await cli(["status", "--json"])).stdout).itemCount).toBe(before);
|
||||
}, 60_000);
|
||||
|
||||
it("imports a Bitwarden CSV and reports the rows it skipped", async () => {
|
||||
const csv = join(home, "bitwarden.csv");
|
||||
writeFileSync(
|
||||
csv,
|
||||
[
|
||||
"folder,favorite,type,name,notes,login_uri,login_username,login_password,login_totp",
|
||||
"Imported,1,login,GitLab,,https://gitlab.com,anthony,<gitlab token>,",
|
||||
",,,,,,,,",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await authed(["import", csv]);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toMatch(/Bitwarden CSV/);
|
||||
expect(result.stdout).toMatch(/Skipped {5}1 rows/);
|
||||
expect(result.stdout).toMatch(/Empty row/);
|
||||
|
||||
const list = await authed(["list", "--search", "GitLab"]);
|
||||
expect(list.stdout).toContain("GitLab");
|
||||
}, 60_000);
|
||||
|
||||
it("skips a duplicate id rather than overwriting, by default", async () => {
|
||||
const before = JSON.parse((await cli(["status", "--json"])).stdout).itemCount;
|
||||
const result = await authed(["import", join(home, "vault.opencreds"), "--passphrase-stdin"], EXPORT_PASSPHRASE);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toMatch(/6 skipped \(skip\)/);
|
||||
expect(JSON.parse((await cli(["status", "--json"])).stdout).itemCount).toBe(before);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("exits 0 on a conforming document", async () => {
|
||||
const result = await cli(["validate", join(home, "plain.json")]);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toMatch(/conforming OpenCreds database/);
|
||||
});
|
||||
|
||||
it("exits 2 with a pointer at the problem", async () => {
|
||||
// C30 — 2 is a validation failure.
|
||||
const broken = join(home, "broken.json");
|
||||
const db = JSON.parse(readFileSync(join(home, "plain.json"), "utf8"));
|
||||
db.items[0].login = { ...db.items[0].login, uris: [{ uri: "https://x", match: "fuzzy" }] };
|
||||
db.items[0].type = "login";
|
||||
writeFileSync(broken, JSON.stringify(db));
|
||||
|
||||
const result = await cli(["validate", broken]);
|
||||
expect(result.code).toBe(2);
|
||||
expect(result.stdout).toMatch(/uris\/0\/match/);
|
||||
expect(result.stdout).toMatch(/not a valid match rule/);
|
||||
});
|
||||
|
||||
it("exits 2 on a file that is not JSON at all", async () => {
|
||||
const notJson = join(home, "notes.txt");
|
||||
writeFileSync(notJson, "just some text");
|
||||
const result = await cli(["validate", notJson]);
|
||||
expect(result.code).toBe(2);
|
||||
});
|
||||
});
|
||||
30
packages/opencreds/src/cli.ts
Normal file
30
packages/opencreds/src/cli.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* The standalone `opencreds` binary.
|
||||
*
|
||||
* Exactly the commands `logicsrc creds …` registers, from the same module, so
|
||||
* the two cannot drift — which matters because the specification treats the CLI
|
||||
* as a conformance surface.
|
||||
*/
|
||||
|
||||
import { Command } from "commander";
|
||||
import { registerCredsCommands } from "./commands.js";
|
||||
import { OPENCREDS_VERSION } from "./types.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("opencreds")
|
||||
.description(
|
||||
"OpenCreds: one credential record for logins, cards, identities, notes, keys and " +
|
||||
"accounts, an end-to-end-encrypted vault, and a portable database that moves " +
|
||||
"between products without a plaintext CSV. https://logicsrc.com/opencreds",
|
||||
)
|
||||
.version(`opencreds ${OPENCREDS_VERSION} (@logicsrc/opencreds 0.1.0)`);
|
||||
|
||||
registerCredsCommands(program);
|
||||
|
||||
program.parseAsync(process.argv).catch((err: Error) => {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
940
packages/opencreds/src/commands.ts
Normal file
940
packages/opencreds/src/commands.ts
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
/**
|
||||
* The OpenCreds CLI, registered onto a commander parent.
|
||||
*
|
||||
* These commands ship twice — as `logicsrc creds …` and as the standalone
|
||||
* `opencreds` binary — from this one implementation, because the specification
|
||||
* treats CLI behaviour (flags, output shapes, exit codes) as a conformance
|
||||
* surface and a subcommand that quietly diverged would make the two different
|
||||
* contracts.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { auditEvent } from "./audit.js";
|
||||
import { emitFixtures, formatReport, runConformance } from "./conformance.js";
|
||||
import {
|
||||
DATABASE_EXTENSION,
|
||||
buildManifest,
|
||||
exportDatabase,
|
||||
exportPlaintextDatabase,
|
||||
mergePayload,
|
||||
openDatabase,
|
||||
parseDatabase,
|
||||
readHeader,
|
||||
} from "./database.js";
|
||||
import { CSV_LOSSY_FIELDS, IMPORT_SOURCES, parseCsvImport, toBitwardenCsv } from "./importers.js";
|
||||
import {
|
||||
createItem,
|
||||
decryptItems,
|
||||
encryptItem,
|
||||
isItemType,
|
||||
maskItem,
|
||||
readField,
|
||||
recordPasswordChange,
|
||||
updateItem,
|
||||
} from "./items.js";
|
||||
import { confirm, promptNewSecret, promptSecret, resolveSecretFlag } from "./prompt.js";
|
||||
import { createVaultStore, opencredsHome } from "./store.js";
|
||||
import { SESSION_ENV, clearSession, encodeSession, persistSession, readSession } from "./session.js";
|
||||
import {
|
||||
ITEM_TYPE,
|
||||
ITEM_TYPE_NAMES,
|
||||
OPENCREDS_VERSION,
|
||||
type DatabasePayload,
|
||||
type Item,
|
||||
type ItemTypeName,
|
||||
type MergeStrategy,
|
||||
} from "./types.js";
|
||||
import { createVault, resetRecoveryKey, rewrapUserKey, unlockVault, unlockWithRecoveryKey } from "./vault-key.js";
|
||||
import { formatDiagnostics, hasErrors, validateDocument } from "./validate.js";
|
||||
|
||||
/** Exit codes are part of the contract; see docs/opencreds/cli.md. */
|
||||
export const EXIT = {
|
||||
OK: 0,
|
||||
USAGE: 1,
|
||||
VALIDATION: 2,
|
||||
CRYPTO: 3,
|
||||
REFUSED: 4,
|
||||
} as const;
|
||||
|
||||
class CliError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message: string, code: number): never {
|
||||
throw new CliError(message, code);
|
||||
}
|
||||
|
||||
/** Run a command body, mapping a thrown CliError onto its exit code. */
|
||||
async function run(body: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await body();
|
||||
} catch (err) {
|
||||
const code = err instanceof CliError ? err.code : EXIT.USAGE;
|
||||
process.stderr.write(`${(err as Error).message}\n`);
|
||||
process.exitCode = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface GlobalOptions {
|
||||
home?: string;
|
||||
}
|
||||
|
||||
function storeFor(command: Command) {
|
||||
const opts = command.optsWithGlobals<GlobalOptions>();
|
||||
return createVaultStore(opts.home ?? opencredsHome());
|
||||
}
|
||||
|
||||
function requireMeta(store: ReturnType<typeof createVaultStore>) {
|
||||
const meta = store.readMeta();
|
||||
if (!meta) fail(`No vault at ${store.baseDir} — run \`opencreds init\` first`, EXIT.USAGE);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user key for this invocation.
|
||||
*
|
||||
* A live session is used when there is one; otherwise the master password is
|
||||
* asked for. Nothing else unlocks a vault.
|
||||
*/
|
||||
async function unlock(store: ReturnType<typeof createVaultStore>): Promise<Uint8Array> {
|
||||
const meta = requireMeta(store);
|
||||
const session = readSession(store.baseDir);
|
||||
if (session) return session;
|
||||
const password = await promptSecret("Master password: ");
|
||||
try {
|
||||
return await unlockVault(meta, password);
|
||||
} catch (err) {
|
||||
store.appendAudit(
|
||||
auditEvent({ action: "vault.unlock_failed", namespace: meta.namespace, profile: meta.profile, outcome: "failed" }),
|
||||
);
|
||||
fail((err as Error).message, EXIT.CRYPTO);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPayload(store: ReturnType<typeof createVaultStore>, userKey: Uint8Array): Promise<DatabasePayload> {
|
||||
const meta = requireMeta(store);
|
||||
const { items, failed } = await decryptItems(userKey, store.listEnvelopes(), meta.namespace);
|
||||
if (failed.length > 0) {
|
||||
// Report and continue: a single corrupt row must not hide the rest of a vault.
|
||||
for (const failure of failed) {
|
||||
process.stderr.write(`warning: could not decrypt ${failure.id} — ${failure.error}\n`);
|
||||
}
|
||||
}
|
||||
return { folders: store.readFolders(), items };
|
||||
}
|
||||
|
||||
async function saveItem(
|
||||
store: ReturnType<typeof createVaultStore>,
|
||||
userKey: Uint8Array,
|
||||
item: Item,
|
||||
): Promise<void> {
|
||||
const meta = requireMeta(store);
|
||||
store.writeEnvelope(await encryptItem(userKey, item, meta.namespace));
|
||||
}
|
||||
|
||||
/** Find an item by exact id, then by exact name, then by unique prefix. */
|
||||
function resolveItem(items: Item[], needle: string): Item {
|
||||
const byId = items.find((item) => item.id === needle);
|
||||
if (byId) return byId;
|
||||
const byName = items.filter((item) => item.name === needle);
|
||||
if (byName.length === 1) return byName[0]!;
|
||||
if (byName.length > 1) fail(`"${needle}" matches ${byName.length} items; use an id`, EXIT.USAGE);
|
||||
const byPrefix = items.filter((item) => item.id.startsWith(needle));
|
||||
if (byPrefix.length === 1) return byPrefix[0]!;
|
||||
if (byPrefix.length > 1) fail(`"${needle}" matches ${byPrefix.length} items; use a longer id`, EXIT.USAGE);
|
||||
return fail(`No item matches "${needle}"`, EXIT.USAGE);
|
||||
}
|
||||
|
||||
/** Type flags, kebab-cased from the field-group names. */
|
||||
const TYPE_FLAGS: Record<ItemTypeName, Array<[flag: string, field: string, secret?: boolean]>> = {
|
||||
login: [
|
||||
["--username <value>", "username"],
|
||||
["--password <value>", "password", true],
|
||||
["--totp <value>", "totp", true],
|
||||
],
|
||||
card: [
|
||||
["--cardholder-name <value>", "cardholderName"],
|
||||
["--brand <value>", "brand"],
|
||||
["--number <value>", "number", true],
|
||||
["--exp-month <value>", "expMonth"],
|
||||
["--exp-year <value>", "expYear"],
|
||||
["--code <value>", "code", true],
|
||||
],
|
||||
identity: [
|
||||
["--title <value>", "title"],
|
||||
["--first-name <value>", "firstName"],
|
||||
["--middle-name <value>", "middleName"],
|
||||
["--last-name <value>", "lastName"],
|
||||
["--company <value>", "company"],
|
||||
["--email <value>", "email"],
|
||||
["--phone <value>", "phone"],
|
||||
["--address1 <value>", "address1"],
|
||||
["--address2 <value>", "address2"],
|
||||
["--city <value>", "city"],
|
||||
["--state <value>", "state"],
|
||||
["--postal-code <value>", "postalCode"],
|
||||
["--country <value>", "country"],
|
||||
["--ssn <value>", "ssn", true],
|
||||
["--passport-number <value>", "passportNumber", true],
|
||||
["--license-number <value>", "licenseNumber", true],
|
||||
],
|
||||
note: [],
|
||||
key: [
|
||||
["--key-type <value>", "keyType"],
|
||||
["--algorithm <value>", "algorithm"],
|
||||
["--public-key <value>", "publicKey"],
|
||||
["--private-key <value>", "privateKey", true],
|
||||
["--passphrase <value>", "passphrase", true],
|
||||
["--fingerprint <value>", "fingerprint"],
|
||||
["--value <value>", "value", true],
|
||||
["--path <value>", "path"],
|
||||
["--mode <value>", "mode"],
|
||||
],
|
||||
account: [
|
||||
["--provider <value>", "provider"],
|
||||
["--account-id <value>", "accountId"],
|
||||
["--handle <value>", "handle"],
|
||||
["--email <value>", "email"],
|
||||
["--access-token <value>", "accessToken", true],
|
||||
["--refresh-token <value>", "refreshToken", true],
|
||||
["--token-type <value>", "tokenType"],
|
||||
["--environment <value>", "environment"],
|
||||
],
|
||||
};
|
||||
|
||||
function optionKey(flag: string): string {
|
||||
const long = flag.split(" ")[0]!.replace(/^--/, "");
|
||||
return long.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Build the field group from the parsed flags, resolving any `-` from stdin. */
|
||||
async function groupFromOptions(type: ItemTypeName, opts: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const group: Record<string, unknown> = {};
|
||||
for (const [flag, field] of TYPE_FLAGS[type]) {
|
||||
const raw = opts[optionKey(flag)];
|
||||
if (raw === undefined) continue;
|
||||
const value = await resolveSecretFlag(String(raw));
|
||||
if (value !== undefined) group[field] = value;
|
||||
}
|
||||
if (type === "login" && typeof opts.url === "string") {
|
||||
group.uris = [{ uri: opts.url, match: (opts.match as string) ?? "domain" }];
|
||||
}
|
||||
if (type === "account" && Array.isArray(opts.scope)) {
|
||||
group.scopes = opts.scope as string[];
|
||||
}
|
||||
if (type === "key" && typeof opts.file === "string") {
|
||||
// Reading a key from a file is the common path; it avoids a multi-line
|
||||
// secret in an argument vector entirely.
|
||||
const body = readFileSync(opts.file, "utf8");
|
||||
group.privateKey ??= body;
|
||||
group.path ??= opts.file;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function applyTypeFlags(command: Command, type: ItemTypeName): Command {
|
||||
for (const [flag] of TYPE_FLAGS[type]) {
|
||||
command.option(flag, undefined);
|
||||
}
|
||||
if (type === "login") {
|
||||
command.option("--url <value>", "matching URI");
|
||||
command.option("--match <rule>", "URI match rule", "domain");
|
||||
}
|
||||
if (type === "account") {
|
||||
command.option("--scope <value...>", "OAuth scope (repeatable)");
|
||||
}
|
||||
if (type === "key") {
|
||||
command.option("--file <path>", "read the private key from a file");
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function printItemLine(item: Item): string {
|
||||
const type = item.type.padEnd(8);
|
||||
const id = item.id.slice(0, 8);
|
||||
return `${id} ${type} ${item.name}`;
|
||||
}
|
||||
|
||||
/** Register every OpenCreds command onto `parent`. */
|
||||
export function registerCredsCommands(parent: Command): void {
|
||||
parent.option("--home <dir>", "vault directory (default $OPENCREDS_HOME)");
|
||||
|
||||
// ---------------------------------------------------------------- vault ---
|
||||
|
||||
parent
|
||||
.command("init")
|
||||
.description("create a vault")
|
||||
.option("--namespace <name>", "domain-separation namespace", "opencreds")
|
||||
.option("--iterations <n>", "PBKDF2 iterations", (v: string) => Number.parseInt(v, 10))
|
||||
.option("--password-stdin", "read the master password from stdin instead of prompting twice")
|
||||
.option("--force", "replace an existing vault")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
opts: { namespace: string; iterations?: number; force?: boolean; passwordStdin?: boolean },
|
||||
) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
if (store.exists() && !opts.force) {
|
||||
fail(`A vault already exists at ${store.baseDir}; pass --force to replace it`, EXIT.REFUSED);
|
||||
}
|
||||
// Scripted provisioning reads one line and skips the confirmation; a
|
||||
// person gets asked twice, because a typo'd master password is an
|
||||
// empty vault they cannot open.
|
||||
const password = opts.passwordStdin
|
||||
? ((await resolveSecretFlag("-")) as string)
|
||||
: await promptNewSecret("Master password: ", "Repeat master password: ");
|
||||
if (password.length === 0) fail("A master password is required", EXIT.USAGE);
|
||||
const { meta, recoveryKey } = await createVault(password, {
|
||||
namespace: opts.namespace,
|
||||
...(opts.iterations ? { params: { kdf: "pbkdf2-sha256" as const, iterations: opts.iterations } } : {}),
|
||||
});
|
||||
store.writeMeta(meta);
|
||||
store.appendAudit(auditEvent({ action: "vault.create", namespace: meta.namespace, profile: meta.profile }));
|
||||
|
||||
process.stdout.write(`Vault created at ${store.baseDir}\n\n`);
|
||||
process.stdout.write(` Recovery key ${recoveryKey}\n\n`);
|
||||
process.stdout.write(
|
||||
"Write this down now. It is the only way back into the vault without the\n" +
|
||||
"master password, it is not stored anywhere, and it will not be shown again.\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("unlock")
|
||||
.description("start a session")
|
||||
.option("--persist", "write the session to a 0600 file instead of printing a token")
|
||||
.option("--password-stdin", "read the master password from stdin")
|
||||
.option("--timeout <minutes>", "session lifetime when persisted", (v: string) => Number.parseInt(v, 10), 15)
|
||||
.action(async function (this: Command, opts: { persist?: boolean; timeout: number; passwordStdin?: boolean }) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const meta = requireMeta(store);
|
||||
const password = opts.passwordStdin
|
||||
? ((await resolveSecretFlag("-")) as string)
|
||||
: await promptSecret("Master password: ");
|
||||
let userKey: Uint8Array;
|
||||
try {
|
||||
userKey = await unlockVault(meta, password);
|
||||
} catch (err) {
|
||||
store.appendAudit(auditEvent({ action: "vault.unlock_failed", outcome: "failed" }));
|
||||
fail((err as Error).message, EXIT.CRYPTO);
|
||||
}
|
||||
store.appendAudit(auditEvent({ action: "vault.unlock", namespace: meta.namespace, profile: meta.profile }));
|
||||
|
||||
if (opts.persist) {
|
||||
const path = persistSession(userKey, opts.timeout, store.baseDir);
|
||||
process.stdout.write(`Session written to ${path}, expiring in ${opts.timeout} minutes.\n`);
|
||||
process.stdout.write(
|
||||
"That file holds the key to this vault. Anything that can read it can read\n" +
|
||||
"every item. Run `opencreds lock` when you are done.\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`export ${SESSION_ENV}="${encodeSession(userKey)}"\n`);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("lock")
|
||||
.description("end a persisted session")
|
||||
.action(async function (this: Command) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const removed = clearSession(store.baseDir);
|
||||
process.stdout.write(
|
||||
removed
|
||||
? "Session cleared.\n"
|
||||
: `No persisted session. If you exported ${SESSION_ENV}, unset it.\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("status")
|
||||
.description("vault presence, lock state and item counts (works locked)")
|
||||
.option("--json", "machine-readable output")
|
||||
.action(async function (this: Command, opts: { json?: boolean }) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const meta = store.readMeta();
|
||||
const envelopes = store.listEnvelopes();
|
||||
const counts: Partial<Record<ItemTypeName, number>> = {};
|
||||
for (const envelope of envelopes) {
|
||||
const name = ITEM_TYPE_NAMES.find((n) => ITEM_TYPE[n] === envelope.type);
|
||||
if (name) counts[name] = (counts[name] ?? 0) + 1;
|
||||
}
|
||||
const unlocked = Boolean(readSession(store.baseDir));
|
||||
|
||||
const report = {
|
||||
vault: store.baseDir,
|
||||
present: Boolean(meta),
|
||||
unlocked,
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
namespace: meta?.namespace,
|
||||
profile: meta?.profile,
|
||||
kdf: meta ? `${meta.kdf}/${meta.kdfIterations}` : undefined,
|
||||
itemCount: envelopes.length,
|
||||
types: counts,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (!meta) {
|
||||
process.stdout.write(`No vault at ${store.baseDir}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(` Vault ${store.baseDir}\n`);
|
||||
process.stdout.write(` State ${unlocked ? "unlocked" : "locked"}\n`);
|
||||
process.stdout.write(` Namespace ${meta.namespace} (${meta.profile} profile)\n`);
|
||||
process.stdout.write(` KDF ${meta.kdf}, ${meta.kdfIterations} iterations\n`);
|
||||
process.stdout.write(` Items ${envelopes.length}\n`);
|
||||
for (const name of ITEM_TYPE_NAMES) {
|
||||
if (counts[name]) process.stdout.write(` ${name.padEnd(10)}${counts[name]}\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("recover")
|
||||
.description("unlock with the recovery key and set a new master password")
|
||||
.action(async function (this: Command) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const meta = requireMeta(store);
|
||||
const recoveryKey = await promptSecret("Recovery key: ");
|
||||
let userKey: Uint8Array;
|
||||
try {
|
||||
userKey = await unlockWithRecoveryKey(meta, recoveryKey);
|
||||
} catch (err) {
|
||||
fail((err as Error).message, EXIT.CRYPTO);
|
||||
}
|
||||
const password = await promptNewSecret("New master password: ", "Repeat: ");
|
||||
const rewrapped = await rewrapUserKey(meta, userKey, password);
|
||||
const reset = await resetRecoveryKey(rewrapped, userKey);
|
||||
store.writeMeta(reset.meta);
|
||||
store.appendAudit(auditEvent({ action: "vault.recovery_reset", namespace: meta.namespace }));
|
||||
process.stdout.write(`Master password changed. Not one item was re-encrypted.\n\n`);
|
||||
process.stdout.write(` New recovery key ${reset.recoveryKey}\n\n`);
|
||||
process.stdout.write("The old recovery key no longer works.\n");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- items ---
|
||||
|
||||
const add = parent.command("add").description("add an item");
|
||||
for (const type of ITEM_TYPE_NAMES) {
|
||||
const sub = add
|
||||
.command(type)
|
||||
.description(`add a ${type}`)
|
||||
.requiredOption("--name <value>", "display name")
|
||||
.option("--notes <value>", "notes")
|
||||
.option("--folder <name>", "folder name")
|
||||
.option("--favorite", "mark as a favorite")
|
||||
.option("--json", "print the created item as masked JSON");
|
||||
applyTypeFlags(sub, type);
|
||||
sub.action(async function (this: Command, opts: Record<string, unknown>) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
const group = await groupFromOptions(type, opts);
|
||||
|
||||
let folderId: string | null = null;
|
||||
if (typeof opts.folder === "string" && opts.folder !== "") {
|
||||
const folders = store.readFolders();
|
||||
let folder = folders.find((f) => f.name === opts.folder);
|
||||
if (!folder) {
|
||||
folder = { id: globalThis.crypto.randomUUID(), name: opts.folder };
|
||||
store.writeFolders([...folders, folder]);
|
||||
}
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const item = createItem(type, {
|
||||
name: String(opts.name),
|
||||
notes: typeof opts.notes === "string" ? opts.notes : "",
|
||||
favorite: Boolean(opts.favorite),
|
||||
folderId,
|
||||
[type]: group,
|
||||
} as Partial<Item>);
|
||||
|
||||
await saveItem(store, userKey, item);
|
||||
store.appendAudit(auditEvent({ action: "item.create", itemId: item.id, itemType: type }));
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify(maskItem(item), null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`Added ${type} ${item.id}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
parent
|
||||
.command("list")
|
||||
.description("list items; never prints secret values")
|
||||
.option("--type <type>", "filter by item type")
|
||||
.option("--folder <name>", "filter by folder")
|
||||
.option("--search <text>", "match against the item name")
|
||||
.option("--json", "machine-readable output, masked identically")
|
||||
.action(async function (this: Command, opts: { type?: string; folder?: string; search?: string; json?: boolean }) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
const { items, folders } = await loadPayload(store, userKey);
|
||||
|
||||
if (opts.type && !isItemType(opts.type)) {
|
||||
fail(`Unknown type "${opts.type}"; expected one of ${ITEM_TYPE_NAMES.join(", ")}`, EXIT.USAGE);
|
||||
}
|
||||
const folderId = opts.folder ? folders.find((f) => f.name === opts.folder)?.id : undefined;
|
||||
if (opts.folder && !folderId) fail(`No folder named "${opts.folder}"`, EXIT.USAGE);
|
||||
|
||||
const needle = opts.search?.toLowerCase();
|
||||
const filtered = items
|
||||
.filter((item) => (opts.type ? item.type === opts.type : true))
|
||||
.filter((item) => (folderId ? item.folderId === folderId : true))
|
||||
.filter((item) => (needle ? item.name.toLowerCase().includes(needle) : true))
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify(filtered.map((item) => maskItem(item)), null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
process.stdout.write("No matching items.\n");
|
||||
return;
|
||||
}
|
||||
for (const item of filtered) process.stdout.write(`${printItemLine(item)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("get")
|
||||
.argument("<needle>", "item id or name")
|
||||
.description("show one item, with every secret masked")
|
||||
.option("--field <path>", "a single dotted field path, e.g. login.password")
|
||||
.option("--reveal", "print the value of --field in the clear")
|
||||
.option("--json", "machine-readable output, masked identically")
|
||||
.action(async function (this: Command, needle: string, opts: { field?: string; reveal?: boolean; json?: boolean }) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
const { items } = await loadPayload(store, userKey);
|
||||
const item = resolveItem(items, needle);
|
||||
|
||||
if (opts.reveal) {
|
||||
// Revealing is always a deliberate act naming a single value. There
|
||||
// is no flag that prints a whole item in the clear, because there is
|
||||
// no workflow that needs one.
|
||||
if (!opts.field) fail("--reveal needs --field naming a single value", EXIT.USAGE);
|
||||
const value = readField(item, opts.field);
|
||||
if (value === undefined) fail(`No field "${opts.field}" on this item`, EXIT.USAGE);
|
||||
process.stdout.write(`${value}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const masked = maskItem(item);
|
||||
if (opts.field) {
|
||||
const value = readField(masked, opts.field);
|
||||
if (value === undefined) fail(`No field "${opts.field}" on this item`, EXIT.USAGE);
|
||||
process.stdout.write(`${value}\n`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
const edit = parent.command("edit").description("edit an item");
|
||||
for (const type of ITEM_TYPE_NAMES) {
|
||||
const sub = edit
|
||||
.command(type)
|
||||
.argument("<needle>", "item id or name")
|
||||
.description(`edit a ${type}`)
|
||||
.option("--name <value>", "display name")
|
||||
.option("--notes <value>", "notes")
|
||||
.option("--favorite <bool>", "true or false");
|
||||
applyTypeFlags(sub, type);
|
||||
sub.action(async function (this: Command, needle: string, opts: Record<string, unknown>) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
const { items } = await loadPayload(store, userKey);
|
||||
const item = resolveItem(items, needle);
|
||||
if (item.type !== type) fail(`${item.id} is a ${item.type}, not a ${type}`, EXIT.USAGE);
|
||||
|
||||
const group = await groupFromOptions(type, opts);
|
||||
const patch: Partial<Item> = {};
|
||||
if (typeof opts.name === "string") patch.name = opts.name;
|
||||
if (typeof opts.notes === "string") patch.notes = opts.notes;
|
||||
if (opts.favorite !== undefined) patch.favorite = String(opts.favorite) === "true";
|
||||
|
||||
// A password change is recorded in the item's own history before the
|
||||
// new value overwrites the old one — otherwise the value being replaced
|
||||
// is the one that gets lost.
|
||||
let next = item;
|
||||
if (type === "login" && typeof group.password === "string" && group.password !== item.login?.password) {
|
||||
next = recordPasswordChange(next, group.password);
|
||||
delete group.password;
|
||||
}
|
||||
next = updateItem(next, { ...patch, [type]: group } as Partial<Item>);
|
||||
|
||||
await saveItem(store, userKey, next);
|
||||
store.appendAudit(auditEvent({ action: "item.update", itemId: next.id, itemType: type }));
|
||||
process.stdout.write(`Updated ${next.id}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
parent
|
||||
.command("rm")
|
||||
.argument("<needle>", "item id or name")
|
||||
.description("delete an item")
|
||||
.option("--purge", "delete irrecoverably rather than moving to the trash")
|
||||
.action(async function (this: Command, needle: string, opts: { purge?: boolean }) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const userKey = await unlock(store);
|
||||
const { items } = await loadPayload(store, userKey);
|
||||
const item = resolveItem(items, needle);
|
||||
|
||||
if (opts.purge) {
|
||||
store.deleteEnvelope(item.id);
|
||||
store.appendAudit(auditEvent({ action: "item.purge", itemId: item.id, itemType: item.type }));
|
||||
process.stdout.write(`Purged ${item.id}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope = store.readEnvelope(item.id);
|
||||
if (!envelope) fail(`No stored item ${item.id}`, EXIT.USAGE);
|
||||
const now = new Date();
|
||||
store.writeEnvelope({
|
||||
...envelope,
|
||||
deletedAt: now.toISOString(),
|
||||
purgeAfter: new Date(now.getTime() + 30 * 86_400_000).toISOString(),
|
||||
});
|
||||
store.appendAudit(auditEvent({ action: "item.delete", itemId: item.id, itemType: item.type }));
|
||||
process.stdout.write(`Moved ${item.id} to the trash; recoverable for 30 days\n`);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("restore")
|
||||
.argument("<id>", "item id")
|
||||
.description("restore an item from the trash")
|
||||
.action(async function (this: Command, id: string) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
await unlock(store);
|
||||
const envelope = store.readEnvelope(id);
|
||||
if (!envelope) fail(`No item ${id}`, EXIT.USAGE);
|
||||
store.writeEnvelope({ ...envelope, deletedAt: null, purgeAfter: null });
|
||||
store.appendAudit(auditEvent({ action: "item.restore", itemId: id }));
|
||||
process.stdout.write(`Restored ${id}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- database ---
|
||||
|
||||
parent
|
||||
.command("export")
|
||||
.description("export the vault as an OpenCreds database")
|
||||
.option("--out <file>", "output file", `vault${DATABASE_EXTENSION}`)
|
||||
.option("--passphrase-stdin", "read the export passphrase from stdin")
|
||||
.option("--plaintext", "write every secret in the clear (requires --yes)")
|
||||
.option("--format <format>", "opencreds or bitwarden-csv", "opencreds")
|
||||
.option("--yes", "confirm a plaintext export")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
opts: { out: string; passphraseStdin?: boolean; plaintext?: boolean; format: string; yes?: boolean },
|
||||
) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const meta = requireMeta(store);
|
||||
const userKey = await unlock(store);
|
||||
const payload = await loadPayload(store, userKey);
|
||||
|
||||
const wantsPlaintext = Boolean(opts.plaintext) || opts.format === "bitwarden-csv";
|
||||
|
||||
if (wantsPlaintext) {
|
||||
process.stdout.write(
|
||||
`About to write ${payload.items.length} items to ${opts.out} with every secret in the clear.\n` +
|
||||
"This file cannot be un-leaked, and every password in it should be treated\n" +
|
||||
"as exposed if it is.\n",
|
||||
);
|
||||
if (!opts.yes && !(await confirm("Continue?"))) {
|
||||
fail("Refused: a plaintext export needs --yes", EXIT.REFUSED);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.format === "bitwarden-csv") {
|
||||
const { csv, dropped } = toBitwardenCsv(payload.items, payload.folders);
|
||||
writeFileSync(opts.out, csv, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(opts.out, 0o600);
|
||||
} catch {
|
||||
/* no modes on this platform */
|
||||
}
|
||||
store.appendAudit(
|
||||
auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length }),
|
||||
);
|
||||
process.stdout.write(`Wrote ${opts.out}\n`);
|
||||
const droppedEntries = Object.entries(dropped);
|
||||
if (droppedEntries.length > 0) {
|
||||
process.stdout.write("\nNo CSV has a column for these, so they were not written:\n");
|
||||
for (const [what, count] of droppedEntries) process.stdout.write(` ${String(count).padStart(4)} ${what}\n`);
|
||||
process.stdout.write(`\nThe fields a CSV always loses: ${CSV_LOSSY_FIELDS.join(", ")}.\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.plaintext) {
|
||||
const db = await exportPlaintextDatabase(payload, {
|
||||
namespace: meta.namespace,
|
||||
acknowledged: true,
|
||||
});
|
||||
writeFileSync(opts.out, `${JSON.stringify(db, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(opts.out, 0o600);
|
||||
} catch {
|
||||
/* no modes on this platform */
|
||||
}
|
||||
store.appendAudit(auditEvent({ action: "database.export_plaintext", itemCount: payload.items.length }));
|
||||
process.stdout.write(`Wrote ${opts.out} — unprotected, ${payload.items.length} items.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const passphrase = opts.passphraseStdin
|
||||
? ((await resolveSecretFlag("-")) as string)
|
||||
: await promptNewSecret("Export passphrase: ", "Repeat: ");
|
||||
const db = await exportDatabase(payload, { namespace: meta.namespace, passphrase });
|
||||
writeFileSync(opts.out, `${JSON.stringify(db, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
store.appendAudit(auditEvent({ action: "database.export", itemCount: payload.items.length }));
|
||||
process.stdout.write(
|
||||
`Wrote ${opts.out} — encrypted, ${payload.items.length} items, ${payload.folders.length} folders.\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("import")
|
||||
.argument("<file>", "an OpenCreds database, or a CSV export from another product")
|
||||
.description("import into the vault")
|
||||
.option("--dry-run", "report what would happen and write nothing")
|
||||
.option("--merge <strategy>", "skip, replace or duplicate", "skip")
|
||||
.option("--source <name>", `force a CSV source (${Object.keys(IMPORT_SOURCES).join(", ")})`)
|
||||
.option("--passphrase-stdin", "read the database passphrase from stdin")
|
||||
.option("--allow-unregistered-namespace", "open a database whose namespace is not registered")
|
||||
.action(async function (
|
||||
this: Command,
|
||||
file: string,
|
||||
opts: {
|
||||
dryRun?: boolean;
|
||||
merge: string;
|
||||
source?: string;
|
||||
passphraseStdin?: boolean;
|
||||
allowUnregisteredNamespace?: boolean;
|
||||
},
|
||||
) {
|
||||
await run(async () => {
|
||||
const store = storeFor(this);
|
||||
const meta = requireMeta(store);
|
||||
const userKey = await unlock(store);
|
||||
|
||||
const strategy = opts.merge as MergeStrategy;
|
||||
if (!["skip", "replace", "duplicate"].includes(strategy)) {
|
||||
fail(`Unknown merge strategy "${opts.merge}"`, EXIT.USAGE);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
fail(`Could not read ${file}`, EXIT.USAGE);
|
||||
}
|
||||
|
||||
let incoming: DatabasePayload;
|
||||
let sourceLabel: string;
|
||||
let skipped: Array<{ row: number; reason: string }> = [];
|
||||
|
||||
const isJson = text.trimStart().startsWith("{");
|
||||
if (isJson) {
|
||||
const db = parseDatabase(text);
|
||||
const header = readHeader(db);
|
||||
process.stdout.write(
|
||||
` Source ${file} (opencreds ${header.opencreds}, ` +
|
||||
`${header.protected ? "encrypted" : "PLAINTEXT"}, namespace ${header.namespace})\n`,
|
||||
);
|
||||
if (header.generator) {
|
||||
process.stdout.write(` Exported ${header.exportedAt} by ${header.generator.name} ${header.generator.version}\n`);
|
||||
}
|
||||
|
||||
const passphrase = header.protected
|
||||
? opts.passphraseStdin
|
||||
? ((await resolveSecretFlag("-")) as string)
|
||||
: await promptSecret("Database passphrase: ")
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
incoming = await openDatabase(db, {
|
||||
...(passphrase !== undefined ? { passphrase } : {}),
|
||||
allowUnregisteredNamespace: opts.allowUnregisteredNamespace,
|
||||
});
|
||||
} catch (err) {
|
||||
// A manifest mismatch writes nothing, regardless of flags.
|
||||
fail((err as Error).message, EXIT.CRYPTO);
|
||||
}
|
||||
process.stdout.write(` Manifest verified — ${incoming.items.length} items, ${incoming.folders.length} folders\n\n`);
|
||||
sourceLabel = "opencreds";
|
||||
} else {
|
||||
const parsed = parseCsvImport(text, opts.source ? { source: opts.source } : {});
|
||||
if (!parsed.source) {
|
||||
fail(
|
||||
parsed.skipped[0]?.reason === "Unrecognised export format"
|
||||
? `Could not identify the export format of ${file}; pass --source`
|
||||
: `Nothing to import from ${file}`,
|
||||
EXIT.VALIDATION,
|
||||
);
|
||||
}
|
||||
incoming = { folders: parsed.folders, items: parsed.items };
|
||||
skipped = parsed.skipped;
|
||||
sourceLabel = IMPORT_SOURCES[parsed.source]!.label;
|
||||
process.stdout.write(` Source ${file} (${sourceLabel} CSV)\n\n`);
|
||||
}
|
||||
|
||||
const existing = await loadPayload(store, userKey);
|
||||
const merged = mergePayload(existing, incoming, strategy);
|
||||
|
||||
const counts: Partial<Record<ItemTypeName, number>> = {};
|
||||
for (const item of incoming.items) counts[item.type] = (counts[item.type] ?? 0) + 1;
|
||||
for (const name of ITEM_TYPE_NAMES) {
|
||||
if (counts[name]) process.stdout.write(` ${name.padEnd(10)}${String(counts[name]).padStart(4)}\n`);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`\n Folders ${merged.outcome.foldersAdded} new, ${merged.outcome.foldersMerged} merged\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
` Outcome ${merged.outcome.added} added, ${merged.outcome.replaced} replaced, ` +
|
||||
`${merged.outcome.duplicated} duplicated, ${merged.outcome.skipped} skipped (${strategy})\n`,
|
||||
);
|
||||
if (skipped.length > 0) {
|
||||
process.stdout.write(` Skipped ${skipped.length} rows\n`);
|
||||
for (const row of skipped.slice(0, 20)) {
|
||||
process.stdout.write(` row ${row.row}: ${row.reason}\n`);
|
||||
}
|
||||
if (skipped.length > 20) process.stdout.write(` … and ${skipped.length - 20} more\n`);
|
||||
}
|
||||
|
||||
if (opts.dryRun) {
|
||||
process.stdout.write("\n Nothing written. Re-run without --dry-run to import.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
store.writeFolders(merged.folders);
|
||||
for (const item of merged.items) {
|
||||
store.writeEnvelope(await encryptItem(userKey, item, meta.namespace));
|
||||
}
|
||||
store.appendAudit(auditEvent({ action: "database.import", itemCount: incoming.items.length }));
|
||||
process.stdout.write(`\n Imported into ${store.baseDir}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ validate ----
|
||||
|
||||
parent
|
||||
.command("validate")
|
||||
.argument("[file]", "a database or item document; omit with --stdin")
|
||||
.description("check that a document conforms")
|
||||
.option("--stdin", "read the document from stdin")
|
||||
.option("--json", "machine-readable diagnostics")
|
||||
.action(async function (this: Command, file: string | undefined, opts: { stdin?: boolean; json?: boolean }) {
|
||||
await run(async () => {
|
||||
let text: string;
|
||||
if (opts.stdin || !file) {
|
||||
text = await resolveSecretFlag("-") as string;
|
||||
} else {
|
||||
try {
|
||||
text = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
fail(`Could not read ${file}`, EXIT.USAGE);
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
fail(`Not valid JSON: ${(err as Error).message}`, EXIT.VALIDATION);
|
||||
}
|
||||
|
||||
const { kind, diagnostics } = validateDocument(parsed);
|
||||
const failed = hasErrors(diagnostics);
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify({ kind, conformant: !failed, diagnostics }, null, 2)}\n`);
|
||||
} else {
|
||||
// A warning is not a failure, so a document that only warns still
|
||||
// gets told it conforms — otherwise a plaintext database, whose
|
||||
// warning is the whole point of it, looks broken.
|
||||
if (!failed) process.stdout.write(`OK — a conforming OpenCreds ${kind}\n`);
|
||||
if (diagnostics.length > 0) process.stdout.write(`${formatDiagnostics(diagnostics)}\n`);
|
||||
}
|
||||
if (failed) process.exitCode = EXIT.VALIDATION;
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("conformance")
|
||||
.description("run the OpenCreds conformance suite against this implementation")
|
||||
.option("--json", "emit the conformance report as JSON")
|
||||
.option("--emit-fixtures <dir>", "write the generated fixture set to a directory")
|
||||
.action(async function (this: Command, opts: { json?: boolean; emitFixtures?: string }) {
|
||||
await run(async () => {
|
||||
if (opts.emitFixtures) {
|
||||
const fixtures = await emitFixtures();
|
||||
for (const [name, content] of Object.entries(fixtures)) {
|
||||
const target = join(opts.emitFixtures, name);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(
|
||||
target,
|
||||
typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Wrote ${Object.keys(fixtures).length} fixtures to ${opts.emitFixtures}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const report = await runConformance();
|
||||
process.stdout.write(
|
||||
opts.json ? `${JSON.stringify(report, null, 2)}\n` : `${formatReport(report)}\n`,
|
||||
);
|
||||
// A failed MUST is a validation failure, not a crash.
|
||||
if (!report.conformant) process.exitCode = EXIT.VALIDATION;
|
||||
});
|
||||
});
|
||||
|
||||
parent
|
||||
.command("manifest")
|
||||
.argument("<file>", "a plaintext database")
|
||||
.description("recompute the manifest of a plaintext database")
|
||||
.action(async function (this: Command, file: string) {
|
||||
await run(async () => {
|
||||
const db = parseDatabase(readFileSync(file, "utf8"));
|
||||
if (db.protected) fail("Only a plaintext database can be re-manifested here", EXIT.USAGE);
|
||||
const manifest = await buildManifest({ folders: db.folders ?? [], items: db.items ?? [] });
|
||||
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
87
packages/opencreds/src/conformance.test.ts
Normal file
87
packages/opencreds/src/conformance.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emitFixtures, fixturePayload, runConformance } from "./conformance.js";
|
||||
import { openDatabase, parseDatabase } from "./database.js";
|
||||
import { hasErrors, validateDatabase, validateItem } from "./validate.js";
|
||||
import type { Item } from "./types.js";
|
||||
|
||||
describe("the conformance suite", () => {
|
||||
it("reports the reference implementation as conformant", async () => {
|
||||
const report = await runConformance();
|
||||
|
||||
const failures = report.results.filter((r) => r.status === "fail");
|
||||
// Name the failures rather than asserting a count: a failing conformance
|
||||
// run should say which requirement broke, in the test output.
|
||||
expect(failures.map((f) => `${f.id} ${f.title}: ${f.detail}`)).toEqual([]);
|
||||
expect(report.conformant).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("skips only what it cannot run, and only at MAY level", async () => {
|
||||
const report = await runConformance();
|
||||
for (const skipped of report.results.filter((r) => r.status === "skip")) {
|
||||
expect(skipped.level).toBe("MAY");
|
||||
expect(skipped.detail).toBeTruthy();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("emits a report in the shape the specification publishes", async () => {
|
||||
const report = await runConformance();
|
||||
expect(report.type).toBe("opencreds.conformance_report");
|
||||
expect(report.opencreds).toBe("0.1");
|
||||
expect(report.implementation.name).toBe("@logicsrc/opencreds");
|
||||
expect(report.summary.pass + report.summary.fail + report.summary.skip).toBe(report.results.length);
|
||||
for (const result of report.results) {
|
||||
expect(result.id).toMatch(/^C\d+$/);
|
||||
expect(["MUST", "SHOULD", "MAY"]).toContain(result.level);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe("the generated fixtures", () => {
|
||||
it("covers one item of every type", () => {
|
||||
const payload = fixturePayload();
|
||||
expect(payload.items.map((i) => i.type).sort()).toEqual([
|
||||
"account",
|
||||
"card",
|
||||
"identity",
|
||||
"key",
|
||||
"login",
|
||||
"note",
|
||||
]);
|
||||
for (const item of payload.items) expect(hasErrors(validateItem(item))).toBe(false);
|
||||
});
|
||||
|
||||
it("produces valid documents under items/ and database/", async () => {
|
||||
const fixtures = await emitFixtures();
|
||||
|
||||
const plaintext = fixtures["database/plaintext.json"];
|
||||
expect(hasErrors(validateDatabase(plaintext))).toBe(false);
|
||||
|
||||
const opened = await openDatabase(
|
||||
parseDatabase(JSON.stringify(fixtures["database/encrypted.opencreds"])),
|
||||
{ passphrase: "opencreds-fixture" },
|
||||
);
|
||||
expect(opened.items).toHaveLength(6);
|
||||
}, 60_000);
|
||||
|
||||
it("produces documents under invalid/ that a conforming reader must reject", async () => {
|
||||
const fixtures = await emitFixtures();
|
||||
|
||||
// Each of these is a different way to be wrong, and none may be accepted.
|
||||
expect(hasErrors(validateItem(fixtures["invalid/wrong-group.json"] as Item))).toBe(true);
|
||||
await expect(openDatabase(fixtures["invalid/short-payload.json"] as never)).rejects.toThrow(
|
||||
/Manifest does not match/,
|
||||
);
|
||||
await expect(openDatabase(fixtures["invalid/unknown-namespace.json"] as never)).rejects.toThrow(
|
||||
/Unregistered namespace/,
|
||||
);
|
||||
await expect(
|
||||
openDatabase(fixtures["invalid/tampered-manifest.opencreds"] as never, { passphrase: "opencreds-fixture" }),
|
||||
).rejects.toThrow(/wrong passphrase, or the file was altered/);
|
||||
}, 60_000);
|
||||
|
||||
it("ships a README naming the passphrase, so the set is usable alone", async () => {
|
||||
const fixtures = await emitFixtures();
|
||||
expect(String(fixtures["README.txt"])).toContain("opencreds-fixture");
|
||||
}, 60_000);
|
||||
});
|
||||
613
packages/opencreds/src/conformance.ts
Normal file
613
packages/opencreds/src/conformance.ts
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
/**
|
||||
* The conformance suite.
|
||||
*
|
||||
* `docs/opencreds/conformance.md` lists what an implementation must do; this
|
||||
* file is that list, executable. Each check is a self-contained assertion
|
||||
* against the requirement id it carries, so `opencreds conformance` produces a
|
||||
* report a third party can compare against their own.
|
||||
*
|
||||
* Fixtures are generated rather than hand-written. A vector produced by the
|
||||
* reference implementation and then verified by it is worth more than a JSON
|
||||
* file someone typed: the file drifts silently when the format moves, and the
|
||||
* generated one cannot. `emitFixtures` writes them out so another
|
||||
* implementation can be tested against exactly what this one accepts.
|
||||
*/
|
||||
|
||||
import {
|
||||
createItem,
|
||||
decryptItems,
|
||||
encryptItem,
|
||||
maskItem,
|
||||
recordPasswordChange,
|
||||
assertGroupsMatchType,
|
||||
} from "./items.js";
|
||||
import {
|
||||
assertUsableKdfParams,
|
||||
assertUsableNamespace,
|
||||
deriveAuthHash,
|
||||
deriveMasterKey,
|
||||
deriveWrapKey,
|
||||
} from "./kdf.js";
|
||||
import { createVault, rewrapUserKey, unlockVault } from "./vault-key.js";
|
||||
import {
|
||||
buildManifest,
|
||||
exportDatabase,
|
||||
exportPlaintextDatabase,
|
||||
mergePayload,
|
||||
openDatabase,
|
||||
} from "./database.js";
|
||||
import { detectSource, parseCsv, parseCsvImport, DETECT_ORDER } from "./importers.js";
|
||||
import { hasErrors, validateItem } from "./validate.js";
|
||||
import { fromBase64, randomBytes, toBase64 } from "./primitives.js";
|
||||
import {
|
||||
ITEM_TYPE,
|
||||
ITEM_TYPE_NAMES,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
OPENCREDS_VERSION,
|
||||
type DatabasePayload,
|
||||
type Item,
|
||||
type KdfParams,
|
||||
} from "./types.js";
|
||||
|
||||
export type ConformanceLevel = "MUST" | "SHOULD" | "MAY";
|
||||
export type ConformanceStatus = "pass" | "fail" | "skip";
|
||||
|
||||
export interface ConformanceResult {
|
||||
id: string;
|
||||
level: ConformanceLevel;
|
||||
title: string;
|
||||
status: ConformanceStatus;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConformanceReport {
|
||||
type: "opencreds.conformance_report";
|
||||
opencreds: string;
|
||||
implementation: { name: string; version: string };
|
||||
results: ConformanceResult[];
|
||||
summary: { pass: number; fail: number; skip: number };
|
||||
conformant: boolean;
|
||||
}
|
||||
|
||||
interface Check {
|
||||
id: string;
|
||||
level: ConformanceLevel;
|
||||
title: string;
|
||||
run: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** Derivation at the floor: the construction is under test, not the work factor. */
|
||||
const FAST: KdfParams = { kdf: "pbkdf2-sha256", iterations: 100_000 };
|
||||
const PASSPHRASE = "opencreds-fixture";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function assertThrows(body: () => Promise<unknown> | unknown, what: string): Promise<void> {
|
||||
try {
|
||||
await body();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected a refusal: ${what}`);
|
||||
}
|
||||
|
||||
/** One item of every type, so no field group goes unexercised. */
|
||||
export function fixturePayload(): DatabasePayload {
|
||||
const folder = { id: "11111111-1111-4111-8111-111111111111", name: "Work" };
|
||||
return {
|
||||
folders: [folder],
|
||||
items: [
|
||||
createItem("login", {
|
||||
name: "GitHub",
|
||||
folderId: folder.id,
|
||||
login: {
|
||||
username: "anthony",
|
||||
password: "hunter2",
|
||||
totp: "otpauth://totp/GitHub:anthony?secret=JBSWY3DPEHPK3PXP",
|
||||
uris: [{ uri: "https://github.com", match: "domain" }],
|
||||
},
|
||||
history: [{ password: "hunter1", changedAt: "2026-01-04T09:12:00.000Z" }],
|
||||
} as Partial<Item>),
|
||||
createItem("card", {
|
||||
name: "Visa",
|
||||
card: { cardholderName: "A Ettinger", brand: "Visa", number: "4242424242424242", expMonth: "4", expYear: "2029", code: "123" },
|
||||
} as Partial<Item>),
|
||||
createItem("identity", {
|
||||
name: "Me",
|
||||
identity: { firstName: "Anthony", lastName: "Ettinger", ssn: "000-00-0000" },
|
||||
} as Partial<Item>),
|
||||
createItem("note", { name: "WiFi", notes: "the password is on the router" }),
|
||||
// The secret-shaped fields hold obvious placeholders rather than
|
||||
// realistic-looking values. A fixture only has to exercise the field, and
|
||||
// a real-looking PEM header or `sk_live_` prefix in the tree trains both
|
||||
// credential scanners and the people reading their output to shrug at the
|
||||
// shape that matters.
|
||||
createItem("key", {
|
||||
name: "deploy@railway",
|
||||
key: { keyType: "ssh", algorithm: "ed25519", privateKey: "<private key body>", path: "~/.ssh/id_ed25519", mode: "0600" },
|
||||
} as Partial<Item>),
|
||||
createItem("account", {
|
||||
name: "Stripe",
|
||||
account: { provider: "stripe", accessToken: "<access token>", scopes: ["charges:write", "customers:read"], environment: "production" },
|
||||
} as Partial<Item>),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const CHECKS: Check[] = [
|
||||
{
|
||||
id: "C1",
|
||||
level: "MUST",
|
||||
title: "Reads and writes all six item types with their field groups",
|
||||
run: () => {
|
||||
assert(Object.keys(ITEM_TYPE).length === 6, "expected six item types");
|
||||
for (const type of ITEM_TYPE_NAMES) {
|
||||
const item = createItem(type, { name: type });
|
||||
assert(item.type === type, `createItem lost the type ${type}`);
|
||||
if (type !== "note") {
|
||||
assert(typeof (item as Record<string, unknown>)[type] === "object", `${type} has no field group`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C2",
|
||||
level: "MUST",
|
||||
title: "Stamps v, id, type, name, createdAt and updatedAt on every item",
|
||||
run: () => {
|
||||
const item = createItem("login");
|
||||
for (const field of ["v", "id", "type", "name", "createdAt", "updatedAt"] as const) {
|
||||
assert(item[field] !== undefined, `missing ${field}`);
|
||||
}
|
||||
assert(hasErrors(validateItem(item)) === false, "a freshly created item does not validate");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C3",
|
||||
level: "MUST",
|
||||
title: "Preserves unknown top-level item fields on round trip",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const item = createItem("login", { fromTheFuture: { keep: "me" } } as unknown as Partial<Item>);
|
||||
const back = await decryptItems(key, [await encryptItem(key, item)]);
|
||||
assert(
|
||||
JSON.stringify(back.items[0]?.fromTheFuture) === JSON.stringify({ keep: "me" }),
|
||||
"an unknown field was dropped",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C4",
|
||||
level: "MUST",
|
||||
title: "Distinguishes an empty-string field from an absent one",
|
||||
run: () => {
|
||||
const item = createItem("login", { notes: "" });
|
||||
assert("notes" in item && item.notes === "", "an empty string became absent");
|
||||
assert(item.login?.username === "", "a group field lost its empty string");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C5",
|
||||
level: "MUST",
|
||||
title: "Caps password history at 20 entries, newest first",
|
||||
run: () => {
|
||||
let item = createItem("login", { login: { username: "", password: "p0", totp: "", uris: [] } });
|
||||
for (let i = 1; i <= 25; i++) item = recordPasswordChange(item, `p${i}`);
|
||||
assert(item.history?.length === MAX_HISTORY_ENTRIES, `history is ${item.history?.length}, expected ${MAX_HISTORY_ENTRIES}`);
|
||||
assert(item.history?.[0]?.password === "p24", "history is not newest first");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C6",
|
||||
level: "MUST",
|
||||
title: "Rejects a field group that does not match the item's type",
|
||||
run: async () => {
|
||||
const item = { ...createItem("login"), card: { number: "1" } } as unknown as Item;
|
||||
await assertThrows(() => assertGroupsMatchType(item), "a card group on a login item");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C7",
|
||||
level: "SHOULD",
|
||||
title: "Round-trips attachment references without storing blobs",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const attachments = [{ id: "a1", name: "passport.pdf", size: 12, contentType: "application/pdf" }];
|
||||
const item = createItem("note", { name: "docs", attachments } as Partial<Item>);
|
||||
const back = await decryptItems(key, [await encryptItem(key, item)]);
|
||||
assert(JSON.stringify(back.items[0]?.attachments) === JSON.stringify(attachments), "attachments were lost");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C10",
|
||||
level: "MUST",
|
||||
title: "AES-256-GCM with a fresh 96-bit IV per encryption",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const item = createItem("note", { name: "n" });
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 20; i++) seen.add((await encryptItem(key, item)).iv);
|
||||
assert(seen.size === 20, "an IV was reused");
|
||||
assert(fromBase64([...seen][0]!).length === 12, "the IV is not 96 bits");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C11",
|
||||
level: "MUST",
|
||||
title: "Binds the item id as AAD, so a swapped ciphertext fails",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const low = await encryptItem(key, createItem("login", { name: "low" }));
|
||||
const high = await encryptItem(key, createItem("login", { name: "high" }));
|
||||
const swapped = { ...high, ciphertext: low.ciphertext, iv: low.iv };
|
||||
const result = await decryptItems(key, [swapped]);
|
||||
assert(result.failed.length === 1 && result.items.length === 0, "a swapped ciphertext decrypted");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C12",
|
||||
level: "MUST",
|
||||
title: "Verifies the decrypted id against the envelope id",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const envelope = await encryptItem(key, createItem("login"));
|
||||
const moved = { ...envelope, id: "00000000-0000-4000-8000-000000000000" };
|
||||
const result = await decryptItems(key, [moved]);
|
||||
assert(result.failed.length === 1, "a relabelled envelope decrypted");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C13",
|
||||
level: "MUST",
|
||||
title: "Refuses to derive below 100,000 PBKDF2 iterations",
|
||||
run: async () => {
|
||||
await assertThrows(() => assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 1 }), "iterations: 1");
|
||||
await assertThrows(() => assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 99_999 }), "iterations: 99999");
|
||||
assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 100_000 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C14",
|
||||
level: "MUST",
|
||||
title: "Derives wrap, auth and recovery keys under distinct HKDF labels",
|
||||
run: async () => {
|
||||
const master = await deriveMasterKey("correct horse", randomBytes(16), FAST);
|
||||
const wrap = toBase64(await deriveWrapKey(master));
|
||||
const auth = await deriveAuthHash(master);
|
||||
assert(wrap !== auth, "the wrap key and the auth hash are the same value");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C15",
|
||||
level: "MUST",
|
||||
title: "Generates the user key randomly; a password change re-wraps",
|
||||
run: async () => {
|
||||
const { meta, userKey } = await createVault("first", { params: FAST });
|
||||
const item = await encryptItem(userKey, createItem("login", { name: "GitHub" }));
|
||||
const rewrapped = await rewrapUserKey(meta, userKey, "second", FAST);
|
||||
const after = await unlockVault(rewrapped, "second");
|
||||
assert(toBase64(after) === toBase64(userKey), "the user key changed with the password");
|
||||
const back = await decryptItems(after, [item]);
|
||||
assert(back.items.length === 1, "an item stopped decrypting after a password change");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C16",
|
||||
level: "MUST",
|
||||
title: "Returns partial results with a failure list when one item fails",
|
||||
run: async () => {
|
||||
const key = randomBytes(32);
|
||||
const good = await encryptItem(key, createItem("login", { name: "one" }));
|
||||
const bad = await encryptItem(key, createItem("note", { name: "two" }));
|
||||
const bytes = fromBase64(bad.ciphertext);
|
||||
bytes[0] ^= 0xff;
|
||||
const result = await decryptItems(key, [good, { ...bad, ciphertext: toBase64(bytes) }]);
|
||||
assert(result.items.length === 1 && result.failed.length === 1, "one corrupt row hid the rest of the vault");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C17",
|
||||
level: "MUST",
|
||||
title: "Rejects an unregistered namespace unless explicitly opted in",
|
||||
run: async () => {
|
||||
await assertThrows(() => assertUsableNamespace("somebody-elses-vault"), "an unregistered namespace");
|
||||
assertUsableNamespace("somebody-elses-vault", true);
|
||||
assertUsableNamespace("marksyncr");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C18",
|
||||
level: "MUST",
|
||||
title: "Refuses a vault whose profile it does not implement",
|
||||
run: async () => {
|
||||
const { meta } = await createVault("pw", { params: FAST });
|
||||
await assertThrows(() => unlockVault({ ...meta, profile: "team" }, "pw"), "a team-profile vault");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C19",
|
||||
level: "MAY",
|
||||
title: "Supports the team profile",
|
||||
run: () => {
|
||||
// The envelope is profile-independent, but this build has no member
|
||||
// key management of its own; `logicsrc credentials` holds that.
|
||||
throw new SkipError("key management for the team profile lives in @logicsrc/plugin-credential-sharing");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C20",
|
||||
level: "MUST",
|
||||
title: "Writes the encrypted form by default",
|
||||
run: async () => {
|
||||
const db = await exportDatabase(fixturePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
assert(db.protected === true, "the default export was not encrypted");
|
||||
assert(!JSON.stringify(db).includes("hunter2"), "a secret appeared in an encrypted export");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C21",
|
||||
level: "MUST",
|
||||
title: "Binds the header as AAD, so the manifest is authenticated",
|
||||
run: async () => {
|
||||
const db = await exportDatabase(fixturePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
await assertThrows(
|
||||
() => openDatabase({ ...db, manifest: { ...db.manifest, itemCount: 5 } }, { passphrase: PASSPHRASE }),
|
||||
"a restated item count",
|
||||
);
|
||||
await assertThrows(
|
||||
() => openDatabase({ ...db, protected: false } as never, { passphrase: PASSPHRASE }),
|
||||
"a downgrade to unprotected",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C22",
|
||||
level: "MUST",
|
||||
title: "Recomputes and verifies itemCount, types, folderCount and digest",
|
||||
run: async () => {
|
||||
const payload = fixturePayload();
|
||||
const db = await exportPlaintextDatabase(payload, { acknowledged: true });
|
||||
await assertThrows(() => openDatabase({ ...db, items: db.items.slice(0, 3) }), "a truncated payload");
|
||||
const manifest = await buildManifest(payload);
|
||||
assert(manifest.itemCount === 6 && manifest.folderCount === 1, "the manifest miscounted");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C23",
|
||||
level: "MUST",
|
||||
title: "Writes nothing on a manifest mismatch",
|
||||
run: async () => {
|
||||
// openDatabase throws before returning a payload, so a caller has
|
||||
// nothing to write. This asserts the shape that guarantee relies on.
|
||||
const db = await exportPlaintextDatabase(fixturePayload(), { acknowledged: true });
|
||||
let returned: unknown;
|
||||
try {
|
||||
returned = await openDatabase({ ...db, items: db.items.slice(0, 2) });
|
||||
} catch {
|
||||
returned = undefined;
|
||||
}
|
||||
assert(returned === undefined, "a mismatched database still returned a payload");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C24",
|
||||
level: "MUST",
|
||||
title: "Requires an explicit opt-in for the plaintext form",
|
||||
run: async () => {
|
||||
await assertThrows(
|
||||
() => exportPlaintextDatabase(fixturePayload(), { acknowledged: false }),
|
||||
"a plaintext export without acknowledgement",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C25",
|
||||
level: "MUST",
|
||||
title: "Writes protected: false in a plaintext file's header",
|
||||
run: async () => {
|
||||
const db = await exportPlaintextDatabase(fixturePayload(), { acknowledged: true });
|
||||
assert(db.protected === false, "a plaintext database did not label itself");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C26",
|
||||
level: "MUST",
|
||||
title: "Export → import → export produces byte-identical item records",
|
||||
run: async () => {
|
||||
const payload = fixturePayload();
|
||||
const first = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
const opened = await openDatabase(first, { passphrase: PASSPHRASE });
|
||||
const second = await exportDatabase(opened, { passphrase: PASSPHRASE, params: FAST });
|
||||
const again = await openDatabase(second, { passphrase: PASSPHRASE });
|
||||
assert(JSON.stringify(again.items) === JSON.stringify(payload.items), "items changed across a round trip");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C27",
|
||||
level: "MUST",
|
||||
title: "Does not restamp createdAt / updatedAt on import",
|
||||
run: async () => {
|
||||
const payload = fixturePayload();
|
||||
payload.items[0] = { ...payload.items[0]!, createdAt: "2019-04-01T00:00:00.000Z", updatedAt: "2020-07-09T00:00:00.000Z" };
|
||||
const db = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
const opened = await openDatabase(db, { passphrase: PASSPHRASE });
|
||||
assert(opened.items[0]?.createdAt === "2019-04-01T00:00:00.000Z", "createdAt was restamped");
|
||||
assert(opened.items[0]?.updatedAt === "2020-07-09T00:00:00.000Z", "updatedAt was restamped");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C28",
|
||||
level: "SHOULD",
|
||||
title: "Reports per-strategy merge outcomes rather than one total",
|
||||
run: () => {
|
||||
const existing = fixturePayload();
|
||||
const skipped = mergePayload(existing, { folders: [], items: [existing.items[0]!] }, "skip");
|
||||
const replaced = mergePayload(existing, { folders: [], items: [existing.items[0]!] }, "replace");
|
||||
const duplicated = mergePayload(existing, { folders: [], items: [existing.items[0]!] }, "duplicate");
|
||||
assert(skipped.outcome.skipped === 1, "skip did not report a skip");
|
||||
assert(replaced.outcome.replaced === 1, "replace did not report a replacement");
|
||||
assert(duplicated.outcome.duplicated === 1, "duplicate did not report a duplicate");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C40",
|
||||
level: "MUST",
|
||||
title: "CSV reader handles quotes, newlines, commas, CRLF and a BOM",
|
||||
run: () => {
|
||||
const rows = parseCsv('name,notes\r\n"a, b","he said ""hi""\nsecond"\r\n');
|
||||
assert(rows[0]?.[0] === "name", "the BOM was not stripped");
|
||||
assert(rows[1]?.[0] === "a, b", "a quoted comma was mangled");
|
||||
assert(rows[1]?.[1] === 'he said "hi"\nsecond', "an escaped quote or embedded newline was mangled");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C41",
|
||||
level: "MUST",
|
||||
title: "Reports unmappable rows with row number and reason",
|
||||
run: () => {
|
||||
const result = parseCsvImport("name,url,username,password,note\nGitHub,https://github.com,a,b,\n,,,,\n");
|
||||
assert(result.items.length === 1, "the good row did not import");
|
||||
assert(result.skipped.length === 1 && result.skipped[0]?.row === 3, "the empty row was dropped silently");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C42",
|
||||
level: "MUST",
|
||||
title: "Detects sources most-specific first",
|
||||
run: () => {
|
||||
assert(DETECT_ORDER[0] === "bitwarden", "bitwarden is not asked first");
|
||||
assert(DETECT_ORDER[DETECT_ORDER.length - 1] === "chrome", "chrome is not asked last");
|
||||
assert(detectSource(["name", "url", "username", "password", "note"]) === "chrome", "a chrome export was misidentified");
|
||||
assert(
|
||||
detectSource(["url", "username", "password", "totp", "extra", "name", "grouping", "fav"]) === "lastpass",
|
||||
"a lastpass export was misidentified",
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "C31",
|
||||
level: "MUST",
|
||||
title: "Masks every secret field, including history and hidden fields",
|
||||
run: () => {
|
||||
const item = createItem("login", {
|
||||
login: { username: "anthony", password: "hunter2", totp: "otpauth://x", uris: [] },
|
||||
fields: [{ name: "PIN", value: "1234", type: "hidden" }],
|
||||
history: [{ password: "old", changedAt: new Date().toISOString() }],
|
||||
} as Partial<Item>);
|
||||
const masked = maskItem(item);
|
||||
assert(masked.login?.password !== "hunter2", "a password survived masking");
|
||||
assert(masked.login?.totp !== "otpauth://x", "a TOTP seed survived masking");
|
||||
assert(masked.fields?.[0]?.value !== "1234", "a hidden custom field survived masking");
|
||||
assert(masked.history?.[0]?.password !== "old", "a historical password survived masking");
|
||||
assert(masked.login?.username === "anthony", "masking removed a non-secret field");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Thrown by a check that cannot run here, as opposed to one that failed. */
|
||||
class SkipError extends Error {}
|
||||
|
||||
/** Run the suite. */
|
||||
export async function runConformance(): Promise<ConformanceReport> {
|
||||
const results: ConformanceResult[] = [];
|
||||
|
||||
for (const check of CHECKS) {
|
||||
try {
|
||||
await check.run();
|
||||
results.push({ id: check.id, level: check.level, title: check.title, status: "pass" });
|
||||
} catch (err) {
|
||||
if (err instanceof SkipError) {
|
||||
results.push({ id: check.id, level: check.level, title: check.title, status: "skip", detail: err.message });
|
||||
continue;
|
||||
}
|
||||
results.push({ id: check.id, level: check.level, title: check.title, status: "fail", detail: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
|
||||
|
||||
const summary = {
|
||||
pass: results.filter((r) => r.status === "pass").length,
|
||||
fail: results.filter((r) => r.status === "fail").length,
|
||||
skip: results.filter((r) => r.status === "skip").length,
|
||||
};
|
||||
|
||||
// A skipped MAY does not affect conformance; a skipped or failed MUST does.
|
||||
const conformant = results.every((r) => r.level !== "MUST" || r.status === "pass");
|
||||
|
||||
return {
|
||||
type: "opencreds.conformance_report",
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
implementation: { name: "@logicsrc/opencreds", version: "0.1.0" },
|
||||
results,
|
||||
summary,
|
||||
conformant,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture set, as data.
|
||||
*
|
||||
* Generated from the reference implementation so another implementation can be
|
||||
* tested against exactly what this one produces and accepts. A hand-written
|
||||
* vector drifts silently when the format moves; a generated one cannot.
|
||||
*/
|
||||
export async function emitFixtures(): Promise<Record<string, unknown>> {
|
||||
const payload = fixturePayload();
|
||||
const encrypted = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
const plaintext = await exportPlaintextDatabase(payload, { acknowledged: true });
|
||||
|
||||
const key = randomBytes(32);
|
||||
const envelopes = [];
|
||||
for (const item of payload.items) envelopes.push(await encryptItem(key, item));
|
||||
|
||||
const historyItem = (() => {
|
||||
let item = createItem("login", { name: "capped", login: { username: "", password: "p0", totp: "", uris: [] } });
|
||||
for (let i = 1; i <= 25; i++) item = recordPasswordChange(item, `p${i}`);
|
||||
return item;
|
||||
})();
|
||||
|
||||
return {
|
||||
"README.txt":
|
||||
"OpenCreds 0.1 conformance fixtures, generated by @logicsrc/opencreds.\n" +
|
||||
`The encrypted database opens with the passphrase: ${PASSPHRASE}\n` +
|
||||
"vault/user-key.txt is the base64 key the envelopes in vault/ are under.\n" +
|
||||
"Files under invalid/ MUST be rejected by a conforming implementation.\n",
|
||||
"items/one-of-each.json": payload.items,
|
||||
"items/history-cap.json": historyItem,
|
||||
"items/unknown-fields.json": createItem("login", { fromTheFuture: { keep: "me" } } as unknown as Partial<Item>),
|
||||
"invalid/wrong-group.json": { ...createItem("login"), card: { number: "4242" } },
|
||||
"invalid/weak-kdf.json": { ...(await createVault("pw", { params: FAST })).meta, kdfIterations: 1 },
|
||||
"invalid/unknown-namespace.json": { ...plaintext, namespace: "somebody-elses-vault" },
|
||||
"invalid/short-payload.json": { ...plaintext, items: plaintext.items.slice(0, 3) },
|
||||
"invalid/tampered-manifest.opencreds": { ...encrypted, manifest: { ...encrypted.manifest, itemCount: 5 } },
|
||||
"vault/user-key.txt": toBase64(key),
|
||||
"vault/envelopes.json": envelopes,
|
||||
"vault/meta.json": (await createVault(PASSPHRASE, { params: FAST })).meta,
|
||||
"database/encrypted.opencreds": encrypted,
|
||||
"database/plaintext.json": plaintext,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render a report for a terminal. */
|
||||
export function formatReport(report: ConformanceReport): string {
|
||||
const lines: string[] = [];
|
||||
const width = Math.max(...report.results.map((r) => r.title.length));
|
||||
|
||||
for (const result of report.results) {
|
||||
const mark = result.status === "pass" ? "pass" : result.status === "skip" ? "skip" : "FAIL";
|
||||
lines.push(
|
||||
` ${result.id.padEnd(4)} ${result.level.padEnd(6)} ${result.title.padEnd(width)} ${mark}` +
|
||||
(result.detail ? `\n ${result.detail}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
` ${report.summary.pass} passed, ${report.summary.fail} failed, ${report.summary.skip} skipped — ` +
|
||||
(report.conformant ? "conformant with OpenCreds 0.1" : "NOT conformant"),
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
213
packages/opencreds/src/crypto.test.ts
Normal file
213
packages/opencreds/src/crypto.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
MIN_PBKDF2_ITERATIONS,
|
||||
assertUsableKdfParams,
|
||||
assertUsableNamespace,
|
||||
createItem,
|
||||
createVault,
|
||||
decryptItem,
|
||||
decryptItems,
|
||||
deriveAuthHash,
|
||||
deriveMasterKey,
|
||||
deriveWrapKey,
|
||||
encryptItem,
|
||||
formatRecoveryKey,
|
||||
fromBase64,
|
||||
parseRecoveryKey,
|
||||
randomBytes,
|
||||
resetRecoveryKey,
|
||||
rewrapUserKey,
|
||||
toBase64,
|
||||
unlockVault,
|
||||
unlockWithRecoveryKey,
|
||||
} from "./index.js";
|
||||
import type { KdfParams } from "./index.js";
|
||||
|
||||
// Tests derive at the floor rather than the 600k default: the construction is
|
||||
// what is under test, and the work factor is a parameter of it.
|
||||
const FAST: KdfParams = { kdf: "pbkdf2-sha256", iterations: MIN_PBKDF2_ITERATIONS };
|
||||
|
||||
describe("the item envelope", () => {
|
||||
it("round-trips an item", async () => {
|
||||
const key = randomBytes(32);
|
||||
const item = createItem("login", {
|
||||
name: "GitHub",
|
||||
login: { username: "anthony", password: "hunter2", totp: "", uris: [{ uri: "https://github.com", match: "domain" }] },
|
||||
});
|
||||
|
||||
const envelope = await encryptItem(key, item);
|
||||
expect(envelope.id).toBe(item.id);
|
||||
expect(envelope.type).toBe(1);
|
||||
expect(envelope.ciphertext).not.toContain("hunter2");
|
||||
|
||||
const back = await decryptItem(key, envelope);
|
||||
expect(back).toEqual(item);
|
||||
});
|
||||
|
||||
it("uses a fresh IV for every encryption", async () => {
|
||||
// C10 — with GCM a repeated IV under one key is a break, not a weakness.
|
||||
const key = randomBytes(32);
|
||||
const item = createItem("note", { name: "n" });
|
||||
const ivs = new Set<string>();
|
||||
for (let i = 0; i < 25; i++) ivs.add((await encryptItem(key, item)).iv);
|
||||
expect(ivs.size).toBe(25);
|
||||
});
|
||||
|
||||
it("fails when a ciphertext is moved to another item's row", async () => {
|
||||
// C11 — the swap this prevents: copy a low-value login's ciphertext into a
|
||||
// high-value row and watch what the user does next.
|
||||
const key = randomBytes(32);
|
||||
const low = await encryptItem(key, createItem("login", { name: "low" }));
|
||||
const high = await encryptItem(key, createItem("login", { name: "high" }));
|
||||
|
||||
const swapped = { ...high, ciphertext: low.ciphertext, iv: low.iv };
|
||||
await expect(decryptItem(key, swapped)).rejects.toThrow(/Could not decrypt/);
|
||||
});
|
||||
|
||||
it("fails when the namespace differs", async () => {
|
||||
const key = randomBytes(32);
|
||||
const envelope = await encryptItem(key, createItem("login"), "opencreds");
|
||||
await expect(decryptItem(key, envelope, "marksyncr")).rejects.toThrow(/Could not decrypt/);
|
||||
});
|
||||
|
||||
it("fails when a single byte of the ciphertext is flipped", async () => {
|
||||
const key = randomBytes(32);
|
||||
const envelope = await encryptItem(key, createItem("login", { name: "x" }));
|
||||
const bytes = fromBase64(envelope.ciphertext);
|
||||
bytes[0] ^= 0x01;
|
||||
await expect(decryptItem(key, { ...envelope, ciphertext: toBase64(bytes) })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns the readable items alongside the ones that failed", async () => {
|
||||
// C16 — one corrupt row must not hide the rest of a vault.
|
||||
const key = randomBytes(32);
|
||||
const good1 = await encryptItem(key, createItem("login", { name: "one" }));
|
||||
const good2 = await encryptItem(key, createItem("card", { name: "two" }));
|
||||
const bad = await encryptItem(key, createItem("note", { name: "three" }));
|
||||
const corrupted = fromBase64(bad.ciphertext);
|
||||
corrupted[2] ^= 0xff;
|
||||
|
||||
const result = await decryptItems(key, [good1, { ...bad, ciphertext: toBase64(corrupted) }, good2]);
|
||||
expect(result.items.map((i) => i.name).sort()).toEqual(["one", "two"]);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]?.id).toBe(bad.id);
|
||||
});
|
||||
|
||||
it("refuses to encrypt an item carrying the wrong group", async () => {
|
||||
const key = randomBytes(32);
|
||||
const item = { ...createItem("login"), account: { provider: "x" } };
|
||||
await expect(encryptItem(key, item as never)).rejects.toThrow(/must not carry an account field group/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key derivation", () => {
|
||||
it("refuses a KDF below the floor", () => {
|
||||
// C13 — parameters arrive from a server; iterations:1 would make every
|
||||
// captured auth hash a free offline attack.
|
||||
expect(() => assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 1 })).toThrow(/minimum is 100000/);
|
||||
expect(() => assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 99_999 })).toThrow();
|
||||
expect(assertUsableKdfParams({ kdf: "pbkdf2-sha256", iterations: 100_000 })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refuses argon2id rather than falling back to something weaker", () => {
|
||||
expect(() => assertUsableKdfParams({ kdf: "argon2id", iterations: 600_000 })).toThrow(/not implemented/);
|
||||
});
|
||||
|
||||
it("derives the wrap key and the auth hash independently", async () => {
|
||||
// C14 — this is what lets the auth hash reach a server at all.
|
||||
const master = await deriveMasterKey("correct horse", randomBytes(16), FAST);
|
||||
const wrap = await deriveWrapKey(master);
|
||||
const auth = await deriveAuthHash(master);
|
||||
expect(toBase64(wrap)).not.toBe(auth);
|
||||
});
|
||||
|
||||
it("derives different keys under different namespaces", async () => {
|
||||
const master = await deriveMasterKey("pw", randomBytes(16), FAST);
|
||||
expect(toBase64(await deriveWrapKey(master, "opencreds"))).not.toBe(
|
||||
toBase64(await deriveWrapKey(master, "marksyncr")),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a salt that is too short to be one", async () => {
|
||||
await expect(deriveMasterKey("pw", randomBytes(8), FAST)).rejects.toThrow(/at least 16 bytes/);
|
||||
});
|
||||
|
||||
it("rejects an unregistered namespace unless asked to allow it", () => {
|
||||
// C17 — an arbitrary prefix is an arbitrary derivation.
|
||||
expect(() => assertUsableNamespace("somebody-elses-vault")).toThrow(/Unregistered namespace/);
|
||||
expect(assertUsableNamespace("somebody-elses-vault", true)).toBe("somebody-elses-vault");
|
||||
expect(assertUsableNamespace("marksyncr")).toBe("marksyncr");
|
||||
expect(() => assertUsableNamespace("Not Valid")).toThrow(/Invalid namespace/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the vault", () => {
|
||||
it("creates, locks and unlocks", async () => {
|
||||
const { meta, userKey } = await createVault("correct horse battery staple", { params: FAST });
|
||||
expect(meta.profile).toBe("user");
|
||||
expect(meta.namespace).toBe(DEFAULT_NAMESPACE);
|
||||
expect(meta.protectedUserKey).not.toBe("");
|
||||
|
||||
const unlocked = await unlockVault(meta, "correct horse battery staple");
|
||||
expect(toBase64(unlocked)).toBe(toBase64(userKey));
|
||||
});
|
||||
|
||||
it("rejects the wrong password", async () => {
|
||||
const { meta } = await createVault("right", { params: FAST });
|
||||
await expect(unlockVault(meta, "wrong")).rejects.toThrow(/Wrong master password/);
|
||||
});
|
||||
|
||||
it("generates the user key rather than deriving it, so a password change re-wraps", async () => {
|
||||
// C15 — the alternative rewrites every item, and a partial failure leaves
|
||||
// half a vault on each password.
|
||||
const { meta, userKey } = await createVault("first", { params: FAST });
|
||||
const rewrapped = await rewrapUserKey(meta, userKey, "second", FAST);
|
||||
|
||||
expect(rewrapped.protectedUserKey).not.toBe(meta.protectedUserKey);
|
||||
expect(toBase64(await unlockVault(rewrapped, "second"))).toBe(toBase64(userKey));
|
||||
await expect(unlockVault(rewrapped, "first")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps items readable across a password change", async () => {
|
||||
const { meta, userKey } = await createVault("first", { params: FAST });
|
||||
const envelope = await encryptItem(userKey, createItem("login", { name: "GitHub" }));
|
||||
const rewrapped = await rewrapUserKey(meta, userKey, "second", FAST);
|
||||
const afterKey = await unlockVault(rewrapped, "second");
|
||||
expect((await decryptItem(afterKey, envelope)).name).toBe("GitHub");
|
||||
});
|
||||
|
||||
it("recovers with the recovery key", async () => {
|
||||
const { meta, userKey, recoveryKey } = await createVault("forgotten", { params: FAST });
|
||||
const recovered = await unlockWithRecoveryKey(meta, recoveryKey);
|
||||
expect(toBase64(recovered)).toBe(toBase64(userKey));
|
||||
});
|
||||
|
||||
it("tolerates the transcription a person actually types", async () => {
|
||||
const { meta, userKey, recoveryKey } = await createVault("pw", { params: FAST });
|
||||
const mangled = recoveryKey.toLowerCase().replace(/-/g, " ");
|
||||
expect(toBase64(await unlockWithRecoveryKey(meta, mangled))).toBe(toBase64(userKey));
|
||||
});
|
||||
|
||||
it("invalidates the old recovery key when a new one is issued", async () => {
|
||||
const { meta, userKey, recoveryKey } = await createVault("pw", { params: FAST });
|
||||
const reset = await resetRecoveryKey(meta, userKey);
|
||||
expect(toBase64(await unlockWithRecoveryKey(reset.meta, reset.recoveryKey))).toBe(toBase64(userKey));
|
||||
await expect(unlockWithRecoveryKey(reset.meta, recoveryKey)).rejects.toThrow(/Wrong recovery key/);
|
||||
});
|
||||
|
||||
it("round-trips a recovery key through its display form", () => {
|
||||
const bytes = randomBytes(16);
|
||||
const rendered = formatRecoveryKey(bytes);
|
||||
expect(rendered).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}(-[0-9A-HJKMNP-TV-Z]{1,5})+$/);
|
||||
expect(toBase64(parseRecoveryKey(rendered).slice(0, 16))).toBe(toBase64(bytes));
|
||||
});
|
||||
|
||||
it("refuses a profile it does not implement", async () => {
|
||||
// C18.
|
||||
const { meta } = await createVault("pw", { params: FAST });
|
||||
const team = { ...meta, profile: "team" as const };
|
||||
await expect(unlockVault(team, "pw")).rejects.toThrow(/does not support the "team" profile/);
|
||||
});
|
||||
});
|
||||
248
packages/opencreds/src/database.test.ts
Normal file
248
packages/opencreds/src/database.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildManifest,
|
||||
createItem,
|
||||
exportDatabase,
|
||||
exportPlaintextDatabase,
|
||||
mergePayload,
|
||||
openDatabase,
|
||||
parseDatabase,
|
||||
readHeader,
|
||||
randomBytes,
|
||||
verifyManifest,
|
||||
} from "./index.js";
|
||||
import type { DatabasePayload, EncryptedDatabase, Item, PlaintextDatabase } from "./index.js";
|
||||
|
||||
const PASSPHRASE = "opencreds-fixture";
|
||||
// The construction is under test, not the work factor.
|
||||
const FAST = { kdf: "pbkdf2-sha256" as const, iterations: 100_000 };
|
||||
|
||||
function samplePayload(): DatabasePayload {
|
||||
const work = { id: "11111111-1111-4111-8111-111111111111", name: "Work" };
|
||||
return {
|
||||
folders: [work],
|
||||
items: [
|
||||
createItem("login", {
|
||||
name: "GitHub",
|
||||
folderId: work.id,
|
||||
login: { username: "anthony", password: "hunter2", totp: "", uris: [{ uri: "https://github.com", match: "domain" }] },
|
||||
}),
|
||||
createItem("card", { name: "Visa", card: { number: "4242424242424242", code: "123" } } as Partial<Item>),
|
||||
createItem("key", { name: "deploy", key: { keyType: "ssh", path: "~/.ssh/id_ed25519", mode: "0600" } } as Partial<Item>),
|
||||
createItem("account", { name: "Stripe", account: { provider: "stripe", scopes: ["charges:write"] } } as Partial<Item>),
|
||||
createItem("note", { name: "wifi", notes: "the password is on the router" }),
|
||||
createItem("identity", { name: "me", identity: { firstName: "A", lastName: "E" } } as Partial<Item>),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("the manifest", () => {
|
||||
it("counts items by type and digests the ids", async () => {
|
||||
const manifest = await buildManifest(samplePayload());
|
||||
expect(manifest.itemCount).toBe(6);
|
||||
expect(manifest.folderCount).toBe(1);
|
||||
expect(manifest.types).toEqual({ login: 1, card: 1, key: 1, account: 1, note: 1, identity: 1 });
|
||||
expect(manifest.digest).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
});
|
||||
|
||||
it("digests the same items identically whatever order they arrive in", async () => {
|
||||
const payload = samplePayload();
|
||||
const reversed = { ...payload, items: [...payload.items].reverse() };
|
||||
expect((await buildManifest(reversed)).digest).toBe((await buildManifest(payload)).digest);
|
||||
});
|
||||
|
||||
it("reports every disagreement, not just the first", async () => {
|
||||
const payload = samplePayload();
|
||||
const manifest = await buildManifest(payload);
|
||||
const short = { ...payload, items: payload.items.slice(0, 4) };
|
||||
const problems = await verifyManifest(manifest, short);
|
||||
expect(problems.length).toBeGreaterThan(1);
|
||||
expect(problems.join(" ")).toMatch(/says 6 items, payload has 4/);
|
||||
expect(problems.join(" ")).toMatch(/digest does not match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the encrypted database", () => {
|
||||
it("round-trips a whole vault", async () => {
|
||||
// C20, C26.
|
||||
const payload = samplePayload();
|
||||
const db = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
|
||||
expect(db.protected).toBe(true);
|
||||
expect(db.type).toBe("opencreds.database");
|
||||
expect(JSON.stringify(db)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(db)).not.toContain("4242");
|
||||
|
||||
const back = await openDatabase(db, { passphrase: PASSPHRASE });
|
||||
expect(back.items).toEqual(payload.items);
|
||||
expect(back.folders).toEqual(payload.folders);
|
||||
});
|
||||
|
||||
it("exposes the header without the passphrase, and it cannot be lied about", async () => {
|
||||
// C21 — the counts can be previewed, and are authenticated.
|
||||
const db = await exportDatabase(samplePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
const header = readHeader(db);
|
||||
expect(header.manifest.itemCount).toBe(6);
|
||||
expect(header.generator?.name).toBe("@logicsrc/opencreds");
|
||||
|
||||
const lying: EncryptedDatabase = {
|
||||
...db,
|
||||
manifest: { ...db.manifest, itemCount: 5 },
|
||||
};
|
||||
await expect(openDatabase(lying, { passphrase: PASSPHRASE })).rejects.toThrow(/wrong passphrase, or the file was altered/);
|
||||
});
|
||||
|
||||
it("fails when the generator or the export time is edited", async () => {
|
||||
const db = await exportDatabase(samplePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
await expect(
|
||||
openDatabase({ ...db, exportedAt: "2020-01-01T00:00:00.000Z" }, { passphrase: PASSPHRASE }),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
openDatabase({ ...db, generator: { name: "someone-else", version: "9" } }, { passphrase: PASSPHRASE }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("cannot be downgraded to unprotected", async () => {
|
||||
const db = await exportDatabase(samplePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
await expect(
|
||||
openDatabase({ ...db, protected: false } as unknown as EncryptedDatabase, { passphrase: PASSPHRASE }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects the wrong passphrase", async () => {
|
||||
const db = await exportDatabase(samplePayload(), { passphrase: PASSPHRASE, params: FAST });
|
||||
await expect(openDatabase(db, { passphrase: "nope" })).rejects.toThrow(/Could not decrypt/);
|
||||
});
|
||||
|
||||
it("accepts a raw key and then omits the kdf block", async () => {
|
||||
const key = randomBytes(32);
|
||||
const db = await exportDatabase(samplePayload(), { key });
|
||||
expect(db.kdf).toBeUndefined();
|
||||
expect((await openDatabase(db, { key })).items).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("refuses an export with neither a passphrase nor a key", async () => {
|
||||
await expect(exportDatabase(samplePayload(), {})).rejects.toThrow(/needs a passphrase or a raw key/);
|
||||
});
|
||||
|
||||
it("uses a fresh salt and IV per export, so two exports of one vault differ", async () => {
|
||||
const payload = samplePayload();
|
||||
const a = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
const b = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
expect(a.ciphertext).not.toBe(b.ciphertext);
|
||||
expect(a.kdf?.salt).not.toBe(b.kdf?.salt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plaintext database", () => {
|
||||
it("refuses without an explicit acknowledgement", async () => {
|
||||
// C24 — never a default, never an accident.
|
||||
await expect(
|
||||
exportPlaintextDatabase(samplePayload(), { acknowledged: false }),
|
||||
).rejects.toThrow(/every secret in the vault to disk unencrypted/);
|
||||
});
|
||||
|
||||
it("labels itself unprotected in the header", async () => {
|
||||
// C25 — identifiable without parsing the rest of it.
|
||||
const db = await exportPlaintextDatabase(samplePayload(), { acknowledged: true });
|
||||
expect(db.protected).toBe(false);
|
||||
expect(JSON.stringify(db)).toContain("hunter2");
|
||||
expect(db.manifest.itemCount).toBe(6);
|
||||
});
|
||||
|
||||
it("still verifies its manifest on the way back in", async () => {
|
||||
// C22 — unauthenticated, but it still catches truncation.
|
||||
const db = await exportPlaintextDatabase(samplePayload(), { acknowledged: true });
|
||||
const truncated: PlaintextDatabase = { ...db, items: db.items.slice(0, 3) };
|
||||
await expect(openDatabase(truncated)).rejects.toThrow(/Manifest does not match/);
|
||||
expect((await openDatabase(db)).items).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-tripping", () => {
|
||||
it("does not restamp timestamps or drop unknown fields", async () => {
|
||||
// C3, C26, C27 — createdAt is the only evidence of when a password rotated.
|
||||
const payload = samplePayload();
|
||||
payload.items[0] = {
|
||||
...payload.items[0]!,
|
||||
createdAt: "2019-04-01T00:00:00.000Z",
|
||||
updatedAt: "2020-07-09T00:00:00.000Z",
|
||||
fromTheFuture: { keep: "me" },
|
||||
} as Item;
|
||||
|
||||
const once = await exportDatabase(payload, { passphrase: PASSPHRASE, params: FAST });
|
||||
const opened = await openDatabase(once, { passphrase: PASSPHRASE });
|
||||
const twice = await exportDatabase(opened, { passphrase: PASSPHRASE, params: FAST });
|
||||
const again = await openDatabase(twice, { passphrase: PASSPHRASE });
|
||||
|
||||
expect(again.items[0]?.createdAt).toBe("2019-04-01T00:00:00.000Z");
|
||||
expect(again.items[0]?.updatedAt).toBe("2020-07-09T00:00:00.000Z");
|
||||
expect(again.items[0]?.fromTheFuture).toEqual({ keep: "me" });
|
||||
expect(JSON.stringify(again.items)).toBe(JSON.stringify(payload.items));
|
||||
});
|
||||
|
||||
it("rejects an item whose group does not match its type", async () => {
|
||||
const payload = samplePayload();
|
||||
payload.items.push({ ...createItem("login", { name: "bad" }), card: { number: "1" } } as unknown as Item);
|
||||
const db = await exportPlaintextDatabase(payload, { acknowledged: true });
|
||||
await expect(openDatabase(db)).rejects.toThrow(/must not carry a card field group/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merging", () => {
|
||||
const existing = samplePayload();
|
||||
|
||||
it("skips an id that already exists, by default", async () => {
|
||||
const result = mergePayload(existing, { folders: [], items: [existing.items[0]!] });
|
||||
expect(result.outcome).toMatchObject({ added: 0, skipped: 1 });
|
||||
expect(result.items).toHaveLength(existing.items.length);
|
||||
});
|
||||
|
||||
it("replaces on request", async () => {
|
||||
const changed: Item = { ...existing.items[0]!, name: "GitHub (renamed)" };
|
||||
const result = mergePayload(existing, { folders: [], items: [changed] }, "replace");
|
||||
expect(result.outcome).toMatchObject({ replaced: 1 });
|
||||
expect(result.items.find((i) => i.id === changed.id)?.name).toBe("GitHub (renamed)");
|
||||
});
|
||||
|
||||
it("duplicates with a fresh id, keeping both", async () => {
|
||||
const result = mergePayload(existing, { folders: [], items: [existing.items[0]!] }, "duplicate");
|
||||
expect(result.outcome).toMatchObject({ duplicated: 1 });
|
||||
expect(result.items).toHaveLength(existing.items.length + 1);
|
||||
const ids = result.items.map((i) => i.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("adds items that are genuinely new", async () => {
|
||||
const fresh = createItem("login", { name: "GitLab" });
|
||||
const result = mergePayload(existing, { folders: [], items: [fresh] });
|
||||
expect(result.outcome).toMatchObject({ added: 1, skipped: 0 });
|
||||
});
|
||||
|
||||
it("merges a folder of the same name and remaps the items onto it", async () => {
|
||||
const incomingFolder = { id: "22222222-2222-4222-8222-222222222222", name: "Work" };
|
||||
const item = createItem("login", { name: "Jira", folderId: incomingFolder.id });
|
||||
const result = mergePayload(existing, { folders: [incomingFolder], items: [item] });
|
||||
|
||||
expect(result.outcome).toMatchObject({ foldersAdded: 0, foldersMerged: 1 });
|
||||
expect(result.folders).toHaveLength(1);
|
||||
expect(result.items.find((i) => i.id === item.id)?.folderId).toBe(existing.folders[0]!.id);
|
||||
});
|
||||
|
||||
it("adds a folder that is new", async () => {
|
||||
const folder = { id: "33333333-3333-4333-8333-333333333333", name: "Personal" };
|
||||
const result = mergePayload(existing, { folders: [folder], items: [] });
|
||||
expect(result.outcome).toMatchObject({ foldersAdded: 1, foldersMerged: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsing", () => {
|
||||
it("says what a CSV is when one is handed to the database reader", () => {
|
||||
expect(() => parseDatabase("name,url\nx,y")).toThrow(/not a CSV/);
|
||||
});
|
||||
|
||||
it("rejects a JSON document that is not a database", () => {
|
||||
expect(() => parseDatabase('{"hello":"world"}')).toThrow(/Not an OpenCreds database/);
|
||||
});
|
||||
});
|
||||
410
packages/opencreds/src/database.ts
Normal file
410
packages/opencreds/src/database.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
/**
|
||||
* The portable database: a vault as one file.
|
||||
*
|
||||
* Encrypted by default, under a key derived from an *export passphrase* rather
|
||||
* than the vault's user key — a file encrypted under the user key would only
|
||||
* open inside the vault it came from, which is the opposite of portable.
|
||||
*
|
||||
* The header is bound as additional authenticated data over the payload, so the
|
||||
* manifest is authenticated by the same tag as the data. That is the difference
|
||||
* between an import you can trust and a CSV: a CSV truncated at 3,000 rows
|
||||
* imports 3,000 rows and reports success.
|
||||
*/
|
||||
|
||||
import {
|
||||
aesGcmDecrypt,
|
||||
aesGcmEncrypt,
|
||||
fromBase64,
|
||||
randomBytes,
|
||||
sha256,
|
||||
toBase64,
|
||||
utf8Decode,
|
||||
utf8Encode,
|
||||
uuid,
|
||||
} from "./primitives.js";
|
||||
import { DEFAULT_KDF_PARAMS, assertUsableKdfParams, assertUsableNamespace, deriveExportKey } from "./kdf.js";
|
||||
import { SALT_BYTES } from "./vault-key.js";
|
||||
import { assertGroupsMatchType, isItemType } from "./items.js";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
ITEM_TYPE_NAMES,
|
||||
OPENCREDS_VERSION,
|
||||
type Database,
|
||||
type DatabaseHeader,
|
||||
type DatabaseManifest,
|
||||
type DatabasePayload,
|
||||
type EncryptedDatabase,
|
||||
type Folder,
|
||||
type ImportOutcome,
|
||||
type Item,
|
||||
type ItemTypeName,
|
||||
type KdfParams,
|
||||
type MergeStrategy,
|
||||
type Namespace,
|
||||
type PlaintextDatabase,
|
||||
} from "./types.js";
|
||||
|
||||
export const DATABASE_MEDIA_TYPE = "application/vnd.logicsrc.opencreds+json";
|
||||
export const DATABASE_EXTENSION = ".opencreds";
|
||||
|
||||
const GENERATOR = { name: "@logicsrc/opencreds", version: "0.1.0" } as const;
|
||||
|
||||
/**
|
||||
* Build the manifest for a payload.
|
||||
*
|
||||
* The digest is over sorted item ids so a re-ordered payload is detectable —
|
||||
* an importer that silently accepted a reordering would also silently accept a
|
||||
* substitution.
|
||||
*/
|
||||
export async function buildManifest(payload: DatabasePayload): Promise<DatabaseManifest> {
|
||||
const types: Partial<Record<ItemTypeName, number>> = {};
|
||||
for (const item of payload.items) {
|
||||
if (!isItemType(item.type)) continue;
|
||||
types[item.type] = (types[item.type] ?? 0) + 1;
|
||||
}
|
||||
const ids = payload.items.map((item) => item.id).sort();
|
||||
const digest = toBase64(await sha256(utf8Encode(ids.join("\n"))));
|
||||
return {
|
||||
itemCount: payload.items.length,
|
||||
types,
|
||||
folderCount: payload.folders.length,
|
||||
digest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a claimed manifest against what the payload actually holds.
|
||||
*
|
||||
* Returns every disagreement rather than the first, because a person looking at
|
||||
* a failed import wants to know whether one item went missing or the file is
|
||||
* from a different vault entirely.
|
||||
*/
|
||||
export async function verifyManifest(
|
||||
claimed: DatabaseManifest,
|
||||
payload: DatabasePayload,
|
||||
): Promise<string[]> {
|
||||
const actual = await buildManifest(payload);
|
||||
const problems: string[] = [];
|
||||
if (claimed.itemCount !== actual.itemCount) {
|
||||
problems.push(`manifest says ${claimed.itemCount} items, payload has ${actual.itemCount}`);
|
||||
}
|
||||
if (claimed.folderCount !== actual.folderCount) {
|
||||
problems.push(`manifest says ${claimed.folderCount} folders, payload has ${actual.folderCount}`);
|
||||
}
|
||||
if (claimed.digest !== actual.digest) {
|
||||
problems.push("manifest digest does not match the payload's item ids");
|
||||
}
|
||||
for (const type of ITEM_TYPE_NAMES) {
|
||||
const want = claimed.types?.[type] ?? 0;
|
||||
const have = actual.types[type] ?? 0;
|
||||
if (want !== have) problems.push(`manifest says ${want} ${type} items, payload has ${have}`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes bound as AAD.
|
||||
*
|
||||
* Key order is fixed by the specification, because JSON.stringify preserves
|
||||
* insertion order and a header rebuilt in a different order would produce a
|
||||
* different AAD and fail to decrypt on a conforming reader.
|
||||
*/
|
||||
function headerAad(header: DatabaseHeader): Uint8Array {
|
||||
const ordered: Record<string, unknown> = {
|
||||
opencreds: header.opencreds,
|
||||
type: header.type,
|
||||
protected: header.protected,
|
||||
namespace: header.namespace,
|
||||
exportedAt: header.exportedAt,
|
||||
generator: header.generator,
|
||||
kdf: header.kdf,
|
||||
manifest: header.manifest,
|
||||
};
|
||||
for (const key of Object.keys(ordered)) {
|
||||
if (ordered[key] === undefined) delete ordered[key];
|
||||
}
|
||||
return utf8Encode(JSON.stringify(ordered));
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
namespace?: Namespace;
|
||||
/** The passphrase the file is encrypted under. Mutually exclusive with `key`. */
|
||||
passphrase?: string;
|
||||
/** A raw 32-byte export key, for machine-to-machine transfer. Then `kdf` is omitted. */
|
||||
key?: Uint8Array;
|
||||
params?: KdfParams;
|
||||
exportedAt?: string;
|
||||
generator?: { name: string; version: string };
|
||||
}
|
||||
|
||||
/** Export a payload as an encrypted database. */
|
||||
export async function exportDatabase(
|
||||
payload: DatabasePayload,
|
||||
options: ExportOptions,
|
||||
): Promise<EncryptedDatabase> {
|
||||
const namespace = assertUsableNamespace(options.namespace ?? DEFAULT_NAMESPACE, true);
|
||||
if (!options.passphrase && !options.key) {
|
||||
throw new Error("An export needs a passphrase or a raw key");
|
||||
}
|
||||
if (options.passphrase && options.key) {
|
||||
throw new Error("Pass a passphrase or a raw key, not both");
|
||||
}
|
||||
|
||||
const params = assertUsableKdfParams(options.params ?? DEFAULT_KDF_PARAMS);
|
||||
let exportKey: Uint8Array;
|
||||
let kdf: EncryptedDatabase["kdf"];
|
||||
|
||||
if (options.passphrase) {
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
exportKey = await deriveExportKey(options.passphrase, salt, params, namespace);
|
||||
kdf = { kdf: params.kdf, iterations: params.iterations, salt: toBase64(salt) };
|
||||
} else {
|
||||
exportKey = options.key as Uint8Array;
|
||||
if (exportKey.length !== 32) throw new Error("A raw export key must be 32 bytes");
|
||||
}
|
||||
|
||||
const manifest = await buildManifest(payload);
|
||||
const header: DatabaseHeader = {
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
type: "opencreds.database",
|
||||
protected: true,
|
||||
namespace,
|
||||
exportedAt: options.exportedAt ?? new Date().toISOString(),
|
||||
generator: options.generator ?? { ...GENERATOR },
|
||||
...(kdf ? { kdf } : {}),
|
||||
manifest,
|
||||
};
|
||||
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(
|
||||
exportKey,
|
||||
utf8Encode(JSON.stringify({ folders: payload.folders, items: payload.items })),
|
||||
headerAad(header),
|
||||
);
|
||||
|
||||
return { ...header, protected: true, iv: toBase64(iv), ciphertext: toBase64(ciphertext) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a payload in the plaintext form.
|
||||
*
|
||||
* Every secret in the vault, in a file, in the clear. It exists because the
|
||||
* products people move *to* frequently read nothing else, and an export format
|
||||
* that cannot express that gets worked around with a script that is worse.
|
||||
*
|
||||
* `acknowledged` is not decoration: a caller must state, in code, that it meant
|
||||
* this. The CLI turns that into a flag and a confirmation.
|
||||
*/
|
||||
export async function exportPlaintextDatabase(
|
||||
payload: DatabasePayload,
|
||||
options: { namespace?: Namespace; acknowledged: boolean; exportedAt?: string; generator?: { name: string; version: string } },
|
||||
): Promise<PlaintextDatabase> {
|
||||
if (!options.acknowledged) {
|
||||
throw new Error(
|
||||
"A plaintext export writes every secret in the vault to disk unencrypted; pass acknowledged: true to proceed",
|
||||
);
|
||||
}
|
||||
const namespace = assertUsableNamespace(options.namespace ?? DEFAULT_NAMESPACE, true);
|
||||
return {
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
type: "opencreds.database",
|
||||
protected: false,
|
||||
namespace,
|
||||
exportedAt: options.exportedAt ?? new Date().toISOString(),
|
||||
generator: options.generator ?? { ...GENERATOR },
|
||||
manifest: await buildManifest(payload),
|
||||
folders: payload.folders,
|
||||
items: payload.items,
|
||||
};
|
||||
}
|
||||
|
||||
export function isEncryptedDatabase(db: Database): db is EncryptedDatabase {
|
||||
return db.protected === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the header of a database without opening it.
|
||||
*
|
||||
* Enough for a preview — version, namespace, export time, generator, and the
|
||||
* counts — and, in the encrypted form, authenticated, so none of it can be
|
||||
* lied about. Everything else needs the passphrase, which is the point.
|
||||
*/
|
||||
export function readHeader(db: Database): DatabaseHeader {
|
||||
return {
|
||||
opencreds: db.opencreds,
|
||||
type: db.type,
|
||||
protected: db.protected,
|
||||
namespace: db.namespace,
|
||||
exportedAt: db.exportedAt,
|
||||
generator: db.generator,
|
||||
kdf: (db as EncryptedDatabase).kdf,
|
||||
manifest: db.manifest,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenOptions {
|
||||
passphrase?: string;
|
||||
key?: Uint8Array;
|
||||
allowUnregisteredNamespace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a database and verify its manifest.
|
||||
*
|
||||
* Throws on a manifest mismatch, and the caller writes nothing — there is no
|
||||
* state in which a conforming implementation reports a complete import of an
|
||||
* incomplete file.
|
||||
*/
|
||||
export async function openDatabase(db: Database, options: OpenOptions = {}): Promise<DatabasePayload> {
|
||||
if (db?.type !== "opencreds.database") throw new Error("Not an OpenCreds database");
|
||||
if (db.opencreds !== OPENCREDS_VERSION) {
|
||||
throw new Error(`Unsupported OpenCreds version: ${String(db.opencreds)}`);
|
||||
}
|
||||
assertUsableNamespace(db.namespace, options.allowUnregisteredNamespace);
|
||||
|
||||
let payload: DatabasePayload;
|
||||
|
||||
if (isEncryptedDatabase(db)) {
|
||||
let exportKey: Uint8Array;
|
||||
if (options.key) {
|
||||
exportKey = options.key;
|
||||
} else if (options.passphrase !== undefined) {
|
||||
if (!db.kdf) throw new Error("This database was encrypted with a raw key, not a passphrase");
|
||||
const params = assertUsableKdfParams({ kdf: db.kdf.kdf, iterations: db.kdf.iterations });
|
||||
exportKey = await deriveExportKey(options.passphrase, fromBase64(db.kdf.salt), params, db.namespace);
|
||||
} else {
|
||||
throw new Error("This database is encrypted; a passphrase or key is required");
|
||||
}
|
||||
|
||||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = await aesGcmDecrypt(
|
||||
exportKey,
|
||||
fromBase64(db.iv),
|
||||
fromBase64(db.ciphertext),
|
||||
headerAad(readHeader(db)),
|
||||
);
|
||||
} catch {
|
||||
// One message for a wrong passphrase and for a tampered header, because
|
||||
// the reader cannot tell them apart and guessing would be worse.
|
||||
throw new Error("Could not decrypt the database — wrong passphrase, or the file was altered");
|
||||
}
|
||||
payload = JSON.parse(utf8Decode(plaintext)) as DatabasePayload;
|
||||
} else {
|
||||
payload = { folders: db.folders ?? [], items: db.items ?? [] };
|
||||
}
|
||||
|
||||
payload.folders ??= [];
|
||||
payload.items ??= [];
|
||||
|
||||
const problems = await verifyManifest(db.manifest, payload);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`Manifest does not match the payload: ${problems.join("; ")}`);
|
||||
}
|
||||
|
||||
for (const item of payload.items) {
|
||||
if (!isItemType(item.type)) throw new Error(`Unknown item type in database: ${String(item.type)}`);
|
||||
assertGroupsMatchType(item);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
items: Item[];
|
||||
folders: Folder[];
|
||||
outcome: ImportOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an incoming payload into an existing vault.
|
||||
*
|
||||
* `skip` is the default because it is the only strategy that cannot lose an
|
||||
* existing credential, and `duplicate` is the only one that cannot lose an
|
||||
* incoming one. Which of those matters is the person's call, not ours — so the
|
||||
* outcome is reported per strategy rather than as a single "imported N".
|
||||
*/
|
||||
export function mergePayload(
|
||||
existing: DatabasePayload,
|
||||
incoming: DatabasePayload,
|
||||
strategy: MergeStrategy = "skip",
|
||||
): MergeResult {
|
||||
const items = [...existing.items];
|
||||
const folders = [...existing.folders];
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
|
||||
const outcome: ImportOutcome = {
|
||||
added: 0,
|
||||
replaced: 0,
|
||||
duplicated: 0,
|
||||
skipped: 0,
|
||||
foldersAdded: 0,
|
||||
foldersMerged: 0,
|
||||
};
|
||||
|
||||
// Folder ids collide the same way item ids do. An incoming folder whose id
|
||||
// exists keeps the existing one, and incoming folderIds are remapped onto it.
|
||||
const folderRemap = new Map<string, string>();
|
||||
for (const folder of incoming.folders) {
|
||||
const clash = folderById.get(folder.id);
|
||||
if (clash) {
|
||||
folderRemap.set(folder.id, clash.id);
|
||||
outcome.foldersMerged += 1;
|
||||
continue;
|
||||
}
|
||||
const sameName = folders.find((f) => f.name === folder.name);
|
||||
if (sameName) {
|
||||
folderRemap.set(folder.id, sameName.id);
|
||||
outcome.foldersMerged += 1;
|
||||
continue;
|
||||
}
|
||||
folders.push(folder);
|
||||
folderById.set(folder.id, folder);
|
||||
outcome.foldersAdded += 1;
|
||||
}
|
||||
|
||||
for (const raw of incoming.items) {
|
||||
const item: Item = {
|
||||
...raw,
|
||||
folderId: raw.folderId ? (folderRemap.get(raw.folderId) ?? raw.folderId) : (raw.folderId ?? null),
|
||||
};
|
||||
const clash = byId.get(item.id);
|
||||
|
||||
if (!clash) {
|
||||
items.push(item);
|
||||
byId.set(item.id, item);
|
||||
outcome.added += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strategy === "skip") {
|
||||
outcome.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (strategy === "replace") {
|
||||
items[items.indexOf(clash)] = item;
|
||||
byId.set(item.id, item);
|
||||
outcome.replaced += 1;
|
||||
continue;
|
||||
}
|
||||
const copy: Item = { ...item, id: uuid() };
|
||||
items.push(copy);
|
||||
byId.set(copy.id, copy);
|
||||
outcome.duplicated += 1;
|
||||
}
|
||||
|
||||
return { items, folders, outcome };
|
||||
}
|
||||
|
||||
/** Parse a database from a file's text, with a useful error for the common mistakes. */
|
||||
export function parseDatabase(text: string): Database {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("Not valid JSON — an OpenCreds database is a JSON document, not a CSV");
|
||||
}
|
||||
const db = parsed as Database;
|
||||
if (db?.type !== "opencreds.database") throw new Error("Not an OpenCreds database");
|
||||
return db;
|
||||
}
|
||||
219
packages/opencreds/src/importers.test.ts
Normal file
219
packages/opencreds/src/importers.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DETECT_ORDER, detectSource, parseCsv, parseCsvImport, rowsToObjects, toBitwardenCsv } from "./index.js";
|
||||
import { createItem } from "./index.js";
|
||||
import type { Item } from "./index.js";
|
||||
|
||||
describe("the CSV reader", () => {
|
||||
it("reads quoted fields containing commas, newlines and escaped quotes", () => {
|
||||
// C40 — notes fields contain everything, and a naive split(',') mangles
|
||||
// every export that has one.
|
||||
const rows = parseCsv('name,notes\n"a, b","he said ""hi""\nsecond line"\n');
|
||||
expect(rows).toEqual([
|
||||
["name", "notes"],
|
||||
["a, b", 'he said "hi"\nsecond line'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles CRLF", () => {
|
||||
expect(parseCsv("a,b\r\n1,2\r\n")).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips a UTF-8 BOM so the first column name is usable", () => {
|
||||
// Chrome and Excel both emit one; unhandled it breaks every lookup.
|
||||
const rows = parseCsv("name,url\nGitHub,https://github.com\n");
|
||||
expect(rows[0]?.[0]).toBe("name");
|
||||
});
|
||||
|
||||
it("keeps empty trailing fields", () => {
|
||||
expect(parseCsv("a,b,c\n1,,3\n")).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing useful for an empty document", () => {
|
||||
expect(parseCsv("")).toEqual([]);
|
||||
expect(rowsToObjects(parseCsv("only,headers"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("lowercases and trims header names", () => {
|
||||
const objects = rowsToObjects(parseCsv(" Name , URL \nGitHub,https://github.com\n"));
|
||||
expect(objects[0]).toEqual({ name: "GitHub", url: "https://github.com" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("source detection", () => {
|
||||
it("asks the most specific detector first", () => {
|
||||
// C42 — Chrome's columns are a subset of 1Password's.
|
||||
expect(DETECT_ORDER[0]).toBe("bitwarden");
|
||||
expect(DETECT_ORDER.indexOf("chrome")).toBe(DETECT_ORDER.length - 1);
|
||||
});
|
||||
|
||||
it("identifies each product from its header row", () => {
|
||||
expect(detectSource(["folder", "type", "name", "login_uri", "login_username", "login_password"])).toBe("bitwarden");
|
||||
expect(detectSource(["url", "username", "password", "totp", "extra", "name", "grouping", "fav"])).toBe("lastpass");
|
||||
expect(detectSource(["account", "login name", "password", "web site", "comments"])).toBe("keepass");
|
||||
expect(detectSource(["title", "url", "username", "password", "otpauth", "notes", "type"])).toBe("onepassword");
|
||||
expect(detectSource(["name", "url", "username", "password", "note"])).toBe("chrome");
|
||||
expect(detectSource(["nothing", "familiar"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("importing", () => {
|
||||
it("maps a Bitwarden export across all four of its types", () => {
|
||||
const csv = [
|
||||
"folder,favorite,type,name,notes,login_uri,login_username,login_password,login_totp,card_number,card_code,card_expmonth,card_expyear,identity_firstname,identity_lastname",
|
||||
"Work,1,login,GitHub,,https://github.com,anthony,hunter2,otpauth://x,,,,,,",
|
||||
",,card,Visa,my card,,,,,4242424242424242,123,4,26,,",
|
||||
",,identity,Me,,,,,,,,,,Anthony,Ettinger",
|
||||
",,securenote,WiFi,on the router,,,,,,,,,,",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.source).toBe("bitwarden");
|
||||
expect(result.items).toHaveLength(4);
|
||||
|
||||
const login = result.items.find((i) => i.type === "login")!;
|
||||
expect(login.name).toBe("GitHub");
|
||||
expect(login.favorite).toBe(true);
|
||||
expect(login.login?.password).toBe("hunter2");
|
||||
expect(login.login?.totp).toBe("otpauth://x");
|
||||
expect(login.login?.uris).toEqual([{ uri: "https://github.com", match: "domain" }]);
|
||||
expect(result.folders.map((f) => f.name)).toEqual(["Work"]);
|
||||
expect(login.folderId).toBe(result.folders[0]?.id);
|
||||
|
||||
const card = result.items.find((i) => i.type === "card")!;
|
||||
expect(card.card).toMatchObject({ number: "4242424242424242", code: "123", expMonth: "4", expYear: "2026" });
|
||||
|
||||
const identity = result.items.find((i) => i.type === "identity")!;
|
||||
expect(identity.identity).toMatchObject({ firstName: "Anthony", lastName: "Ettinger" });
|
||||
|
||||
const note = result.items.find((i) => i.type === "note")!;
|
||||
expect(note.notes).toBe("on the router");
|
||||
expect(note.name).toBe("WiFi");
|
||||
});
|
||||
|
||||
it("expands a two-digit expiry year", () => {
|
||||
const csv = "type,name,card_expyear,login_password\ncard,V,26,\n";
|
||||
expect(parseCsvImport(csv, { source: "bitwarden" }).items[0]?.card?.expYear).toBe("2026");
|
||||
});
|
||||
|
||||
it("names a Chrome row from its host when the export had none", () => {
|
||||
const csv = "name,url,username,password,note\n,https://www.github.com/login,anthony,hunter2,\n";
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.source).toBe("chrome");
|
||||
expect(result.items[0]?.name).toBe("github.com");
|
||||
});
|
||||
|
||||
it("reads a LastPass secure note by its sentinel URL", () => {
|
||||
const csv = [
|
||||
"url,username,password,totp,extra,name,grouping,fav",
|
||||
"http://sn,,,,the note body,WiFi,Home,0",
|
||||
"https://github.com,anthony,hunter2,,,GitHub,Work,1",
|
||||
"",
|
||||
].join("\n");
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.source).toBe("lastpass");
|
||||
expect(result.items.find((i) => i.name === "WiFi")?.type).toBe("note");
|
||||
const login = result.items.find((i) => i.name === "GitHub")!;
|
||||
expect(login.type).toBe("login");
|
||||
expect(login.favorite).toBe(true);
|
||||
expect(result.folders.map((f) => f.name).sort()).toEqual(["Home", "Work"]);
|
||||
});
|
||||
|
||||
it("maps a KeePass export", () => {
|
||||
const csv = [
|
||||
'"Account","Login Name","Password","Web Site","Comments","Group"',
|
||||
'"GitHub","anthony","hunter2","https://github.com","a note","Dev"',
|
||||
"",
|
||||
].join("\n");
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.source).toBe("keepass");
|
||||
expect(result.items[0]).toMatchObject({ name: "GitHub", notes: "a note" });
|
||||
expect(result.items[0]?.login?.username).toBe("anthony");
|
||||
});
|
||||
|
||||
it("maps a 1Password export", () => {
|
||||
const csv = "title,url,username,password,otpauth,notes,type\nGitHub,https://github.com,anthony,hunter2,otpauth://y,note,login\n";
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.source).toBe("onepassword");
|
||||
expect(result.items[0]?.login?.totp).toBe("otpauth://y");
|
||||
});
|
||||
|
||||
it("reports a blank row rather than dropping it silently", () => {
|
||||
// C41 — the person still has the source file, and only knows to go back
|
||||
// for it if they are told.
|
||||
const csv = "name,url,username,password,note\nGitHub,https://github.com,a,b,\n,,,,\n";
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.skipped).toEqual([{ row: 3, reason: "Empty row" }]);
|
||||
});
|
||||
|
||||
it("reports an unrecognised format instead of importing nothing quietly", () => {
|
||||
const result = parseCsvImport("alpha,beta\n1,2\n");
|
||||
expect(result.source).toBeNull();
|
||||
expect(result.skipped[0]?.reason).toBe("Unrecognised export format");
|
||||
});
|
||||
|
||||
it("reports a file with no rows", () => {
|
||||
expect(parseCsvImport("").skipped[0]?.reason).toBe("No rows found");
|
||||
});
|
||||
|
||||
it("honours a forced source over detection", () => {
|
||||
const csv = "name,url,username,password,note\nGitHub,https://github.com,a,b,\n";
|
||||
expect(parseCsvImport(csv, { source: "onepassword" }).source).toBe("onepassword");
|
||||
});
|
||||
|
||||
it("survives a quoting torture file", () => {
|
||||
const csv =
|
||||
'name,url,username,password,note\r\n' +
|
||||
'"Weird, Inc.",https://weird.example,"user""quoted","p,a,s,s","line one\nline two, with comma"\r\n';
|
||||
const result = parseCsvImport(csv);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]?.name).toBe("Weird, Inc.");
|
||||
expect(result.items[0]?.login?.username).toBe('user"quoted');
|
||||
expect(result.items[0]?.login?.password).toBe("p,a,s,s");
|
||||
expect(result.items[0]?.notes).toBe("line one\nline two, with comma");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exporting to CSV", () => {
|
||||
it("writes logins and notes, and reports what no CSV can carry", () => {
|
||||
const folder = { id: "11111111-1111-4111-8111-111111111111", name: "Work" };
|
||||
const items: Item[] = [
|
||||
createItem("login", {
|
||||
name: "GitHub",
|
||||
folderId: folder.id,
|
||||
login: { username: "anthony", password: "hunter2", totp: "", uris: [{ uri: "https://github.com", match: "exact" }] },
|
||||
history: [{ password: "old", changedAt: new Date().toISOString() }],
|
||||
} as Partial<Item>),
|
||||
createItem("key", { name: "deploy key" }),
|
||||
createItem("account", { name: "Stripe" }),
|
||||
createItem("card", { name: "Visa" }),
|
||||
];
|
||||
|
||||
const { csv, dropped } = toBitwardenCsv(items, [folder]);
|
||||
expect(csv.split("\n")[0]).toContain("login_password");
|
||||
expect(csv).toContain("hunter2");
|
||||
expect(csv).toContain("Work");
|
||||
// The three types and the history have no column anywhere.
|
||||
expect(dropped["key items"]).toBe(1);
|
||||
expect(dropped["account items"]).toBe(1);
|
||||
expect(dropped["card items"]).toBe(1);
|
||||
expect(dropped["password history"]).toBe(1);
|
||||
expect(dropped["URI match rules"]).toBe(1);
|
||||
});
|
||||
|
||||
it("quotes a value containing a comma so the file reads back", () => {
|
||||
const items = [createItem("login", { name: "Weird, Inc.", notes: 'say "hi"' })];
|
||||
const { csv } = toBitwardenCsv(items, []);
|
||||
const rows = parseCsv(csv);
|
||||
expect(rows[1]?.[3]).toBe("Weird, Inc.");
|
||||
expect(rows[1]?.[4]).toBe('say "hi"');
|
||||
});
|
||||
});
|
||||
496
packages/opencreds/src/importers.ts
Normal file
496
packages/opencreds/src/importers.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
/**
|
||||
* Importing from other password managers.
|
||||
*
|
||||
* Every supported source exports CSV, so the work is one correct CSV reader
|
||||
* plus a column mapping per product. The reader is hand-written rather than
|
||||
* pulled from a dependency because this runs inside a browser extension's
|
||||
* service worker as well as a CLI — and because the failure mode of a sloppy
|
||||
* parser here is silently importing half of somebody's passwords.
|
||||
*
|
||||
* Nothing in this file touches the network or the crypto. It turns text into
|
||||
* plain item objects; the caller encrypts them.
|
||||
*/
|
||||
|
||||
import { createItem } from "./items.js";
|
||||
import { uuid } from "./primitives.js";
|
||||
import type { Folder, Item, ParsedImport, SkippedRow } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse CSV into rows of cells.
|
||||
*
|
||||
* Handles quoted fields, escaped quotes (`""`), embedded newlines and commas,
|
||||
* and both CRLF and LF line endings — all of which appear in real exports,
|
||||
* because notes fields contain everything. A naive `split(',')` mangles any
|
||||
* export containing a note with a comma in it, which is most of them.
|
||||
*/
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
// Strip a UTF-8 BOM — Chrome and Excel both emit one, and it would otherwise
|
||||
// become part of the first header name and break every column lookup.
|
||||
const input = String(text || "").replace(/^/, "");
|
||||
|
||||
while (i < input.length) {
|
||||
const char = input[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += char;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === ",") {
|
||||
row.push(field);
|
||||
field = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "\r") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === "\n") {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
field += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Whatever is buffered when the input ends is the last field, unless the file
|
||||
// ended with a newline and there is nothing pending.
|
||||
if (field !== "" || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn rows into objects keyed by header name, lowercased and trimmed so that
|
||||
* column-name casing differences between export versions stop mattering.
|
||||
*/
|
||||
export function rowsToObjects(rows: string[][]): Array<Record<string, string>> {
|
||||
if (rows.length < 2) return [];
|
||||
const headers = rows[0]!.map((h) => h.trim().toLowerCase());
|
||||
return rows.slice(1).map((row) => {
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((header, i) => {
|
||||
obj[header] = row[i] ?? "";
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
/** First non-empty value among the given column names. */
|
||||
function firstOf(row: Record<string, string>, ...names: string[]): string {
|
||||
for (const name of names) {
|
||||
const value = row[name];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== "") {
|
||||
return String(value).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truthy(value: string): boolean {
|
||||
const v = value.trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "yes";
|
||||
}
|
||||
|
||||
/** Best-effort hostname, used to name an item whose export had no title. */
|
||||
function hostOf(uri: string): string {
|
||||
if (!uri) return "";
|
||||
try {
|
||||
return new URL(uri).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-digit years are expanded to 20xx; a card that expired in 1926 is a typo. */
|
||||
function expandYear(value: string): string {
|
||||
const clean = value.trim();
|
||||
if (/^\d{2}$/.test(clean)) return `20${clean}`;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** A folder assigner that reuses an id per name across a whole import. */
|
||||
function folderAssigner(): { idFor(name: string): string | null; folders: Folder[] } {
|
||||
const byName = new Map<string, Folder>();
|
||||
return {
|
||||
idFor(name: string): string | null {
|
||||
const clean = name.trim();
|
||||
if (!clean) return null;
|
||||
let folder = byName.get(clean);
|
||||
if (!folder) {
|
||||
folder = { id: uuid(), name: clean };
|
||||
byName.set(clean, folder);
|
||||
}
|
||||
return folder.id;
|
||||
},
|
||||
get folders() {
|
||||
return [...byName.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Mapper = (row: Record<string, string>, folders: ReturnType<typeof folderAssigner>) => Item;
|
||||
|
||||
export interface ImportSource {
|
||||
label: string;
|
||||
detect: (headers: string[]) => boolean;
|
||||
map: Mapper;
|
||||
}
|
||||
|
||||
/** Bitwarden: name, login_uri, login_username, login_password, login_totp, notes, type */
|
||||
function mapBitwardenRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): Item {
|
||||
const type = firstOf(row, "type").toLowerCase();
|
||||
const common = {
|
||||
name: firstOf(row, "name"),
|
||||
notes: firstOf(row, "notes"),
|
||||
favorite: truthy(firstOf(row, "favorite")),
|
||||
folderId: folders.idFor(firstOf(row, "folder")),
|
||||
};
|
||||
|
||||
if (type === "card") {
|
||||
return createItem("card", {
|
||||
...common,
|
||||
card: {
|
||||
cardholderName: firstOf(row, "card_cardholdername"),
|
||||
brand: firstOf(row, "card_brand"),
|
||||
number: firstOf(row, "card_number"),
|
||||
expMonth: firstOf(row, "card_expmonth"),
|
||||
expYear: expandYear(firstOf(row, "card_expyear")),
|
||||
code: firstOf(row, "card_code"),
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
if (type === "identity") {
|
||||
return createItem("identity", {
|
||||
...common,
|
||||
identity: {
|
||||
title: firstOf(row, "identity_title"),
|
||||
firstName: firstOf(row, "identity_firstname"),
|
||||
middleName: firstOf(row, "identity_middlename"),
|
||||
lastName: firstOf(row, "identity_lastname"),
|
||||
username: firstOf(row, "identity_username"),
|
||||
company: firstOf(row, "identity_company"),
|
||||
email: firstOf(row, "identity_email"),
|
||||
phone: firstOf(row, "identity_phone"),
|
||||
address1: firstOf(row, "identity_address1"),
|
||||
address2: firstOf(row, "identity_address2"),
|
||||
address3: firstOf(row, "identity_address3"),
|
||||
city: firstOf(row, "identity_city"),
|
||||
state: firstOf(row, "identity_state"),
|
||||
postalCode: firstOf(row, "identity_postalcode"),
|
||||
country: firstOf(row, "identity_country"),
|
||||
ssn: firstOf(row, "identity_ssn"),
|
||||
passportNumber: firstOf(row, "identity_passportnumber"),
|
||||
licenseNumber: firstOf(row, "identity_licensenumber"),
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
if (type === "note" || type === "securenote") {
|
||||
return createItem("note", common as Partial<Item>);
|
||||
}
|
||||
|
||||
const uri = firstOf(row, "login_uri", "uri");
|
||||
return createItem("login", {
|
||||
...common,
|
||||
name: common.name || hostOf(uri),
|
||||
login: {
|
||||
username: firstOf(row, "login_username", "username"),
|
||||
password: firstOf(row, "login_password", "password"),
|
||||
totp: firstOf(row, "login_totp", "totp"),
|
||||
uris: uri ? [{ uri, match: "domain" as const }] : [],
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/** 1Password: title, url, username, password, otpauth, notes, type */
|
||||
function mapOnePasswordRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): Item {
|
||||
const uri = firstOf(row, "url", "website");
|
||||
return createItem("login", {
|
||||
name: firstOf(row, "title", "name") || hostOf(uri),
|
||||
notes: firstOf(row, "notes", "note"),
|
||||
folderId: folders.idFor(firstOf(row, "vault", "tags")),
|
||||
login: {
|
||||
username: firstOf(row, "username"),
|
||||
password: firstOf(row, "password"),
|
||||
totp: firstOf(row, "otpauth", "totp"),
|
||||
uris: uri ? [{ uri, match: "domain" as const }] : [],
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/** Chrome: name, url, username, password, note */
|
||||
function mapChromeRow(row: Record<string, string>): Item {
|
||||
const uri = firstOf(row, "url");
|
||||
return createItem("login", {
|
||||
name: firstOf(row, "name") || hostOf(uri),
|
||||
notes: firstOf(row, "note", "notes"),
|
||||
login: {
|
||||
username: firstOf(row, "username"),
|
||||
password: firstOf(row, "password"),
|
||||
totp: "",
|
||||
uris: uri ? [{ uri, match: "domain" as const }] : [],
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/**
|
||||
* LastPass: url, username, password, totp, extra, name, grouping, fav
|
||||
*
|
||||
* LastPass writes the literal `http://sn` in `url` for a secure note, which is
|
||||
* the only reliable way to tell one from a login in its export.
|
||||
*/
|
||||
function mapLastPassRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): Item {
|
||||
const uri = firstOf(row, "url");
|
||||
const common = {
|
||||
name: firstOf(row, "name") || hostOf(uri),
|
||||
notes: firstOf(row, "extra", "notes"),
|
||||
favorite: truthy(firstOf(row, "fav")),
|
||||
folderId: folders.idFor(firstOf(row, "grouping")),
|
||||
};
|
||||
if (uri === "http://sn" || uri === "http://sn/") {
|
||||
return createItem("note", common as Partial<Item>);
|
||||
}
|
||||
return createItem("login", {
|
||||
...common,
|
||||
login: {
|
||||
username: firstOf(row, "username"),
|
||||
password: firstOf(row, "password"),
|
||||
totp: firstOf(row, "totp"),
|
||||
uris: uri ? [{ uri, match: "domain" as const }] : [],
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/** KeePass CSV: "Account","Login Name","Password","Web Site","Comments","Group" */
|
||||
function mapKeePassRow(row: Record<string, string>, folders: ReturnType<typeof folderAssigner>): Item {
|
||||
const uri = firstOf(row, "web site", "url", "website");
|
||||
return createItem("login", {
|
||||
name: firstOf(row, "account", "title") || hostOf(uri),
|
||||
notes: firstOf(row, "comments", "notes"),
|
||||
folderId: folders.idFor(firstOf(row, "group")),
|
||||
login: {
|
||||
username: firstOf(row, "login name", "user name", "username"),
|
||||
password: firstOf(row, "password"),
|
||||
totp: firstOf(row, "totp", "otp"),
|
||||
uris: uri ? [{ uri, match: "domain" as const }] : [],
|
||||
},
|
||||
} as Partial<Item>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported sources. Each `detect` looks at the header row, so a person can
|
||||
* drop in a file without first telling us where it came from.
|
||||
*/
|
||||
export const IMPORT_SOURCES: Readonly<Record<string, ImportSource>> = Object.freeze({
|
||||
bitwarden: {
|
||||
label: "Bitwarden",
|
||||
detect: (headers) => headers.includes("login_uri") || headers.includes("login_password"),
|
||||
map: mapBitwardenRow,
|
||||
},
|
||||
lastpass: {
|
||||
label: "LastPass",
|
||||
detect: (headers) => headers.includes("grouping") && headers.includes("url"),
|
||||
map: mapLastPassRow,
|
||||
},
|
||||
keepass: {
|
||||
label: "KeePass",
|
||||
detect: (headers) =>
|
||||
(headers.includes("account") && headers.includes("login name")) ||
|
||||
(headers.includes("group") && headers.includes("password") && headers.includes("web site")),
|
||||
map: mapKeePassRow,
|
||||
},
|
||||
onepassword: {
|
||||
label: "1Password",
|
||||
detect: (headers) => headers.includes("url") && headers.includes("username") && headers.includes("type"),
|
||||
map: mapOnePasswordRow,
|
||||
},
|
||||
chrome: {
|
||||
label: "Chrome",
|
||||
detect: (headers) => headers.includes("url") && headers.includes("username") && headers.includes("password"),
|
||||
map: mapChromeRow,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Identify which product produced an export.
|
||||
*
|
||||
* Order matters: Chrome's columns are a subset of 1Password's, and LastPass's
|
||||
* overlap both, so the more specific detector has to be asked first.
|
||||
*/
|
||||
export const DETECT_ORDER: readonly string[] = ["bitwarden", "lastpass", "keepass", "onepassword", "chrome"];
|
||||
|
||||
export function detectSource(headers: string[]): string | null {
|
||||
for (const key of DETECT_ORDER) {
|
||||
if (IMPORT_SOURCES[key]!.detect(headers)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSV export into vault items.
|
||||
*
|
||||
* A row that cannot be mapped is reported rather than dropped — an import that
|
||||
* silently loses credentials is worse than one that says what it could not
|
||||
* read, because the person still has the source file and only knows to go back
|
||||
* for it if they are told.
|
||||
*/
|
||||
export function parseCsvImport(text: string, options: { source?: string } = {}): ParsedImport {
|
||||
const rows = parseCsv(text);
|
||||
if (rows.length < 2) {
|
||||
return { source: null, items: [], folders: [], skipped: [{ row: 0, reason: "No rows found" }] };
|
||||
}
|
||||
|
||||
const headers = rows[0]!.map((h) => h.trim().toLowerCase());
|
||||
const detected = options.source || detectSource(headers);
|
||||
|
||||
if (!detected || !IMPORT_SOURCES[detected]) {
|
||||
return {
|
||||
source: null,
|
||||
items: [],
|
||||
folders: [],
|
||||
skipped: [{ row: 0, reason: "Unrecognised export format" }],
|
||||
};
|
||||
}
|
||||
|
||||
const { map } = IMPORT_SOURCES[detected]!;
|
||||
const objects = rowsToObjects(rows);
|
||||
const folders = folderAssigner();
|
||||
const items: Item[] = [];
|
||||
const skipped: SkippedRow[] = [];
|
||||
|
||||
objects.forEach((row, index) => {
|
||||
try {
|
||||
const item = map(row, folders);
|
||||
// A login with neither a username nor a password carries nothing worth
|
||||
// importing, and usually comes from a trailing blank line. That is a
|
||||
// different fact from "could not map", and should read differently.
|
||||
const isEmptyLogin =
|
||||
item.type === "login" && !item.login?.username && !item.login?.password && !item.name;
|
||||
if (isEmptyLogin) {
|
||||
skipped.push({ row: index + 2, reason: "Empty row" });
|
||||
return;
|
||||
}
|
||||
items.push(item);
|
||||
} catch (err) {
|
||||
skipped.push({ row: index + 2, reason: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
return { source: detected, items, folders: folders.folders, skipped };
|
||||
}
|
||||
|
||||
/** Fields no product's CSV has a column for. Named in the output rather than discovered later. */
|
||||
export const CSV_LOSSY_FIELDS: readonly string[] = Object.freeze([
|
||||
"password history",
|
||||
"custom fields",
|
||||
"attachments",
|
||||
"URI match rules",
|
||||
"key items",
|
||||
"account items",
|
||||
]);
|
||||
|
||||
const BITWARDEN_COLUMNS = [
|
||||
"folder",
|
||||
"favorite",
|
||||
"type",
|
||||
"name",
|
||||
"notes",
|
||||
"fields",
|
||||
"reprompt",
|
||||
"login_uri",
|
||||
"login_username",
|
||||
"login_password",
|
||||
"login_totp",
|
||||
] as const;
|
||||
|
||||
function csvCell(value: string): string {
|
||||
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Bitwarden-shaped CSV, because that is the format most other products
|
||||
* import best. This is a plaintext export and carries every warning that
|
||||
* implies; what it drops is returned rather than left to be discovered.
|
||||
*/
|
||||
export function toBitwardenCsv(
|
||||
items: Item[],
|
||||
folders: Folder[],
|
||||
): { csv: string; dropped: Record<string, number> } {
|
||||
const folderName = new Map(folders.map((f) => [f.id, f.name]));
|
||||
const dropped: Record<string, number> = {};
|
||||
const bump = (what: string, n = 1): void => {
|
||||
if (n > 0) dropped[what] = (dropped[what] ?? 0) + n;
|
||||
};
|
||||
|
||||
const lines = [BITWARDEN_COLUMNS.join(",")];
|
||||
|
||||
for (const item of items) {
|
||||
bump("password history", item.history?.length ?? 0);
|
||||
bump("custom fields", item.fields?.length ?? 0);
|
||||
bump("attachments", item.attachments?.length ?? 0);
|
||||
|
||||
if (item.type === "key" || item.type === "account") {
|
||||
bump(`${item.type} items`, 1);
|
||||
continue;
|
||||
}
|
||||
if (item.type === "card" || item.type === "identity") {
|
||||
// Bitwarden's CSV does carry these columns, but a round trip through the
|
||||
// login-shaped subset would silently blank them. Report instead.
|
||||
bump(`${item.type} items`, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const uri = item.login?.uris?.[0]?.uri ?? "";
|
||||
if ((item.login?.uris?.length ?? 0) > 1) bump("extra URIs", item.login!.uris.length - 1);
|
||||
if (item.login?.uris?.some((u) => u.match && u.match !== "domain")) bump("URI match rules", 1);
|
||||
|
||||
lines.push(
|
||||
[
|
||||
csvCell(item.folderId ? (folderName.get(item.folderId) ?? "") : ""),
|
||||
item.favorite ? "1" : "",
|
||||
item.type === "note" ? "note" : "login",
|
||||
csvCell(item.name),
|
||||
csvCell(item.notes ?? ""),
|
||||
"",
|
||||
"",
|
||||
csvCell(uri),
|
||||
csvCell(item.login?.username ?? ""),
|
||||
csvCell(item.login?.password ?? ""),
|
||||
csvCell(item.login?.totp ?? ""),
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
|
||||
return { csv: `${lines.join("\n")}\n`, dropped };
|
||||
}
|
||||
199
packages/opencreds/src/index.ts
Normal file
199
packages/opencreds/src/index.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* @logicsrc/opencreds — the OpenCreds reference implementation.
|
||||
*
|
||||
* OpenCreds is a LogicSRC OpenSpec for credential records and portable vaults:
|
||||
* what a credential item is, how a vault is encrypted, and what a vault looks
|
||||
* like as a file. It exists because leaving a password manager currently means
|
||||
* writing every secret you own to disk in the clear, and losing whatever the
|
||||
* spreadsheet had no column for.
|
||||
*
|
||||
* Nothing in this package sends anything anywhere. There is no account, no
|
||||
* server, and no network call — which is what makes "the server cannot read the
|
||||
* vault" a property of the code rather than a promise in the marketing copy.
|
||||
*
|
||||
* Typical use:
|
||||
*
|
||||
* const { meta, userKey, recoveryKey } = await createVault(password);
|
||||
* // show recoveryKey to the person, exactly once
|
||||
*
|
||||
* const item = createItem("login", { name: "GitHub", login: { username, password } });
|
||||
* const envelope = await encryptItem(userKey, item); // ciphertext only
|
||||
*
|
||||
* const db = await exportDatabase({ folders, items }, { passphrase });
|
||||
* // ... hand db to another product ...
|
||||
* const payload = await openDatabase(db, { passphrase }); // manifest verified
|
||||
*
|
||||
* Specification: https://logicsrc.com/opencreds
|
||||
*/
|
||||
|
||||
export {
|
||||
IV_BYTES,
|
||||
KEY_BYTES,
|
||||
MIN_SALT_BYTES,
|
||||
randomBytes,
|
||||
utf8Encode,
|
||||
utf8Decode,
|
||||
toBase64,
|
||||
fromBase64,
|
||||
toHex,
|
||||
fromHex,
|
||||
timingSafeEqual,
|
||||
uuid,
|
||||
pbkdf2,
|
||||
hkdf,
|
||||
sha256,
|
||||
aesGcmEncrypt,
|
||||
aesGcmDecrypt,
|
||||
} from "./primitives.js";
|
||||
|
||||
export {
|
||||
KDF,
|
||||
DEFAULT_KDF_PARAMS,
|
||||
MIN_PBKDF2_ITERATIONS,
|
||||
wrapLabel,
|
||||
authLabel,
|
||||
recoveryLabel,
|
||||
itemLabel,
|
||||
databaseLabel,
|
||||
assertUsableNamespace,
|
||||
assertUsableKdfParams,
|
||||
deriveMasterKey,
|
||||
deriveWrapKey,
|
||||
deriveAuthHash,
|
||||
deriveRecoveryWrapKey,
|
||||
deriveExportKey,
|
||||
deriveAll,
|
||||
} from "./kdf.js";
|
||||
|
||||
export {
|
||||
SALT_BYTES,
|
||||
RECOVERY_KEY_BYTES,
|
||||
formatRecoveryKey,
|
||||
parseRecoveryKey,
|
||||
createVault,
|
||||
createTeamVault,
|
||||
assertProfile,
|
||||
unlockVault,
|
||||
unlockWithRecoveryKey,
|
||||
rewrapUserKey,
|
||||
resetRecoveryKey,
|
||||
type CreatedVault,
|
||||
} from "./vault-key.js";
|
||||
|
||||
export {
|
||||
isItemType,
|
||||
createItem,
|
||||
assertGroupsMatchType,
|
||||
recordPasswordChange,
|
||||
updateItem,
|
||||
encryptItem,
|
||||
decryptItem,
|
||||
decryptItems,
|
||||
maskItem,
|
||||
readField,
|
||||
SECRET_FIELDS,
|
||||
type DecryptResult,
|
||||
} from "./items.js";
|
||||
|
||||
export {
|
||||
DATABASE_MEDIA_TYPE,
|
||||
DATABASE_EXTENSION,
|
||||
buildManifest,
|
||||
verifyManifest,
|
||||
exportDatabase,
|
||||
exportPlaintextDatabase,
|
||||
isEncryptedDatabase,
|
||||
readHeader,
|
||||
openDatabase,
|
||||
mergePayload,
|
||||
parseDatabase,
|
||||
type ExportOptions,
|
||||
type OpenOptions,
|
||||
type MergeResult,
|
||||
} from "./database.js";
|
||||
|
||||
export {
|
||||
IMPORT_SOURCES,
|
||||
DETECT_ORDER,
|
||||
CSV_LOSSY_FIELDS,
|
||||
parseCsv,
|
||||
rowsToObjects,
|
||||
detectSource,
|
||||
parseCsvImport,
|
||||
toBitwardenCsv,
|
||||
type ImportSource,
|
||||
} from "./importers.js";
|
||||
|
||||
export {
|
||||
validateItem,
|
||||
validateDatabase,
|
||||
validateDocument,
|
||||
hasErrors,
|
||||
formatDiagnostics,
|
||||
looksLikeDatabase,
|
||||
looksLikeItem,
|
||||
type Diagnostic,
|
||||
} from "./validate.js";
|
||||
|
||||
export { createVaultStore, opencredsHome, type VaultStore } from "./store.js";
|
||||
|
||||
export { auditEvent, fingerprint, type AuditInput } from "./audit.js";
|
||||
|
||||
export {
|
||||
runConformance,
|
||||
emitFixtures,
|
||||
fixturePayload,
|
||||
formatReport,
|
||||
type ConformanceLevel,
|
||||
type ConformanceReport,
|
||||
type ConformanceResult,
|
||||
type ConformanceStatus,
|
||||
} from "./conformance.js";
|
||||
|
||||
export {
|
||||
OPENCREDS_VERSION,
|
||||
ITEM_TYPE,
|
||||
ITEM_TYPE_NAME,
|
||||
ITEM_TYPE_NAMES,
|
||||
ITEM_SCHEMA_VERSION,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
DEFAULT_NAMESPACE,
|
||||
REGISTERED_NAMESPACES,
|
||||
NAMESPACE_PATTERN,
|
||||
} from "./types.js";
|
||||
|
||||
export type {
|
||||
AccountGroup,
|
||||
AttachmentRef,
|
||||
AuditAction,
|
||||
AuditEvent,
|
||||
CardGroup,
|
||||
CustomField,
|
||||
Database,
|
||||
DatabaseHeader,
|
||||
DatabaseManifest,
|
||||
DatabasePayload,
|
||||
EncryptedDatabase,
|
||||
Envelope,
|
||||
FieldKind,
|
||||
Folder,
|
||||
HistoryEntry,
|
||||
IdentityGroup,
|
||||
ImportOutcome,
|
||||
Item,
|
||||
ItemTypeName,
|
||||
ItemUri,
|
||||
KdfName,
|
||||
KdfParams,
|
||||
KeyGroup,
|
||||
KeyKind,
|
||||
LoginGroup,
|
||||
MergeStrategy,
|
||||
Namespace,
|
||||
ParsedImport,
|
||||
PlaintextDatabase,
|
||||
Profile,
|
||||
SkippedRow,
|
||||
UriMatch,
|
||||
VaultMeta,
|
||||
} from "./types.js";
|
||||
146
packages/opencreds/src/items.test.ts
Normal file
146
packages/opencreds/src/items.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_HISTORY_ENTRIES,
|
||||
assertGroupsMatchType,
|
||||
createItem,
|
||||
maskItem,
|
||||
readField,
|
||||
recordPasswordChange,
|
||||
updateItem,
|
||||
} from "./index.js";
|
||||
import type { Item } from "./index.js";
|
||||
|
||||
describe("the item model", () => {
|
||||
it("creates one item per type, each with its own field group and no other", () => {
|
||||
// C1, C6.
|
||||
for (const type of ["login", "card", "identity", "note", "key", "account"] as const) {
|
||||
const item = createItem(type, { name: `a ${type}` });
|
||||
expect(item.type).toBe(type);
|
||||
expect(item.name).toBe(`a ${type}`);
|
||||
if (type !== "note") expect(item[type]).toBeTypeOf("object");
|
||||
for (const other of ["login", "card", "identity", "key", "account"] as const) {
|
||||
if (other !== type) expect(item[other]).toBeUndefined();
|
||||
}
|
||||
expect(() => assertGroupsMatchType(item)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("stamps the required fields on every item", () => {
|
||||
// C2.
|
||||
const item = createItem("login");
|
||||
expect(item.v).toBe(1);
|
||||
expect(item.id).toMatch(/^[0-9a-f]{8}-/);
|
||||
expect(Date.parse(item.createdAt)).not.toBeNaN();
|
||||
expect(Date.parse(item.updatedAt)).not.toBeNaN();
|
||||
});
|
||||
|
||||
it("rejects a field group that does not match the type", () => {
|
||||
// C6 — a card group on a login is a broken importer or a smuggled field.
|
||||
const item = { ...createItem("login"), card: { number: "4242" } } as unknown as Item;
|
||||
expect(() => assertGroupsMatchType(item)).toThrow(/must not carry a card field group/);
|
||||
});
|
||||
|
||||
it("preserves unknown top-level fields", () => {
|
||||
// C3 — an item from a later version must survive a round trip through this one.
|
||||
const item = createItem("login", { futureField: { anything: true } } as unknown as Partial<Item>);
|
||||
expect(item.futureField).toEqual({ anything: true });
|
||||
const edited = updateItem(item, { name: "renamed" });
|
||||
expect(edited.futureField).toEqual({ anything: true });
|
||||
});
|
||||
|
||||
it("distinguishes an empty string from an absent field", () => {
|
||||
// C4.
|
||||
const item = createItem("login", { notes: "" });
|
||||
expect(item.notes).toBe("");
|
||||
expect("notes" in item).toBe(true);
|
||||
expect(item.login?.username).toBe("");
|
||||
});
|
||||
|
||||
it("records the value being replaced, newest first", () => {
|
||||
let item = createItem("login", { login: { username: "a", password: "first", totp: "", uris: [] } });
|
||||
item = recordPasswordChange(item, "second");
|
||||
item = recordPasswordChange(item, "third");
|
||||
|
||||
expect(item.login?.password).toBe("third");
|
||||
expect(item.history?.map((h) => h.password)).toEqual(["second", "first"]);
|
||||
});
|
||||
|
||||
it("does not record a change when the password did not change", () => {
|
||||
let item = createItem("login", { login: { username: "a", password: "same", totp: "", uris: [] } });
|
||||
item = recordPasswordChange(item, "same");
|
||||
expect(item.history).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("caps history at twenty entries and keeps the newest", () => {
|
||||
// C5 — the blob is rewritten on every save, so an uncapped array grows
|
||||
// the ciphertext without bound.
|
||||
let item = createItem("login", { login: { username: "a", password: "p0", totp: "", uris: [] } });
|
||||
for (let i = 1; i <= 25; i++) item = recordPasswordChange(item, `p${i}`);
|
||||
|
||||
expect(item.history).toHaveLength(MAX_HISTORY_ENTRIES);
|
||||
expect(item.history?.[0]?.password).toBe("p24");
|
||||
expect(item.history?.at(-1)?.password).toBe("p5");
|
||||
});
|
||||
|
||||
it("refuses history on anything but a login", () => {
|
||||
const note = createItem("note");
|
||||
expect(() => recordPasswordChange(note, "x")).toThrow(/Only logins/);
|
||||
});
|
||||
|
||||
it("merges a field group on update rather than replacing it", () => {
|
||||
const item = createItem("login", { login: { username: "a", password: "b", totp: "t", uris: [] } });
|
||||
const edited = updateItem(item, { login: { password: "c" } } as Partial<Item>);
|
||||
expect(edited.login).toMatchObject({ username: "a", password: "c", totp: "t" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("masking", () => {
|
||||
it("masks every secret field, including history and hidden custom fields", () => {
|
||||
// C31, C32 — a pipeline is not an authorization.
|
||||
const item = createItem("login", {
|
||||
name: "GitHub",
|
||||
login: { username: "anthony", password: "hunter2", totp: "otpauth://x", uris: [] },
|
||||
fields: [
|
||||
{ name: "PIN", value: "1234", type: "hidden" },
|
||||
{ name: "Team", value: "infra", type: "text" },
|
||||
],
|
||||
history: [{ password: "old", changedAt: new Date().toISOString() }],
|
||||
} as Partial<Item>);
|
||||
|
||||
const masked = maskItem(item);
|
||||
expect(masked.login?.username).toBe("anthony");
|
||||
expect(masked.login?.password).not.toBe("hunter2");
|
||||
expect(masked.login?.totp).not.toBe("otpauth://x");
|
||||
expect(masked.fields?.[0]?.value).not.toBe("1234");
|
||||
expect(masked.fields?.[1]?.value).toBe("infra");
|
||||
expect(masked.history?.[0]?.password).not.toBe("old");
|
||||
// The original is untouched.
|
||||
expect(item.login?.password).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("masks a card number and code, a private key, and an access token", () => {
|
||||
const card = createItem("card", { card: { number: "4242424242424242", code: "123" } } as Partial<Item>);
|
||||
expect(maskItem(card).card?.number).not.toContain("4242");
|
||||
expect(maskItem(card).card?.code).not.toBe("123");
|
||||
|
||||
const key = createItem("key", { key: { privateKey: "-----BEGIN-----", value: "sk_live" } } as Partial<Item>);
|
||||
expect(maskItem(key).key?.privateKey).not.toContain("BEGIN");
|
||||
expect(maskItem(key).key?.value).not.toBe("sk_live");
|
||||
|
||||
const account = createItem("account", { account: { accessToken: "tok", refreshToken: "ref" } } as Partial<Item>);
|
||||
expect(maskItem(account).account?.accessToken).not.toBe("tok");
|
||||
expect(maskItem(account).account?.refreshToken).not.toBe("ref");
|
||||
});
|
||||
|
||||
it("leaves an empty secret empty rather than masking nothing into something", () => {
|
||||
const item = createItem("login");
|
||||
expect(maskItem(item).login?.password).toBe("");
|
||||
});
|
||||
|
||||
it("reads a single field by dotted path", () => {
|
||||
const item = createItem("login", { login: { username: "a", password: "b", totp: "", uris: [] } });
|
||||
expect(readField(item, "login.password")).toBe("b");
|
||||
expect(readField(item, "login.nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
340
packages/opencreds/src/items.ts
Normal file
340
packages/opencreds/src/items.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* The item: creating one, editing one, and putting it in an envelope.
|
||||
*
|
||||
* Logins, cards, identities, notes, keys and accounts are not six features —
|
||||
* they are one record with a `type` and a named field group. Everything the
|
||||
* user typed lives inside a single encrypted blob, which is what makes password
|
||||
* history free: it is an array in that blob, encrypted by construction rather
|
||||
* than needing its own protected table.
|
||||
*/
|
||||
|
||||
import { aesGcmDecrypt, aesGcmEncrypt, fromBase64, toBase64, utf8Decode, utf8Encode, uuid } from "./primitives.js";
|
||||
import { itemLabel } from "./kdf.js";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
ITEM_SCHEMA_VERSION,
|
||||
ITEM_TYPE,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
type AccountGroup,
|
||||
type CardGroup,
|
||||
type Envelope,
|
||||
type IdentityGroup,
|
||||
type Item,
|
||||
type ItemTypeName,
|
||||
type KeyGroup,
|
||||
type LoginGroup,
|
||||
type Namespace,
|
||||
} from "./types.js";
|
||||
|
||||
/** Empty field groups, so every item has a predictable shape. */
|
||||
const EMPTY_FIELDS = Object.freeze({
|
||||
login: (): LoginGroup => ({ username: "", password: "", totp: "", uris: [] }),
|
||||
card: (): CardGroup => ({ cardholderName: "", brand: "", number: "", expMonth: "", expYear: "", code: "" }),
|
||||
identity: (): IdentityGroup => ({
|
||||
title: "",
|
||||
firstName: "",
|
||||
middleName: "",
|
||||
lastName: "",
|
||||
username: "",
|
||||
company: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
address1: "",
|
||||
address2: "",
|
||||
address3: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "",
|
||||
ssn: "",
|
||||
passportNumber: "",
|
||||
licenseNumber: "",
|
||||
}),
|
||||
note: (): Record<string, never> => ({}),
|
||||
key: (): KeyGroup => ({
|
||||
keyType: "",
|
||||
algorithm: "",
|
||||
publicKey: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
fingerprint: "",
|
||||
value: "",
|
||||
path: "",
|
||||
mode: "",
|
||||
expiresAt: "",
|
||||
}),
|
||||
account: (): AccountGroup => ({
|
||||
provider: "",
|
||||
accountId: "",
|
||||
handle: "",
|
||||
email: "",
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
tokenType: "",
|
||||
scopes: [],
|
||||
expiresAt: "",
|
||||
environment: "",
|
||||
}),
|
||||
});
|
||||
|
||||
/** The group names, so a wrong-group check does not have to hard-code them twice. */
|
||||
const GROUP_NAMES: readonly ItemTypeName[] = Object.keys(EMPTY_FIELDS) as ItemTypeName[];
|
||||
|
||||
export function isItemType(value: unknown): value is ItemTypeName {
|
||||
return typeof value === "string" && value in ITEM_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an item.
|
||||
*
|
||||
* The id is generated here, on the client, because it is bound into the
|
||||
* ciphertext as additional authenticated data. Storage records this id rather
|
||||
* than assigning one.
|
||||
*/
|
||||
export function createItem(type: ItemTypeName, fields: Partial<Item> = {}): Item {
|
||||
if (!isItemType(type)) throw new Error(`Unknown item type: ${type}`);
|
||||
const now = new Date().toISOString();
|
||||
const group = EMPTY_FIELDS[type]();
|
||||
const incoming = (fields as Record<string, unknown>)[type];
|
||||
|
||||
const item: Item = {
|
||||
v: ITEM_SCHEMA_VERSION,
|
||||
id: uuid(),
|
||||
type,
|
||||
name: "",
|
||||
favorite: false,
|
||||
folderId: null,
|
||||
notes: "",
|
||||
history: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...stripGroups(fields),
|
||||
};
|
||||
|
||||
if (type !== "note") {
|
||||
(item as Record<string, unknown>)[type] = {
|
||||
...group,
|
||||
...(typeof incoming === "object" && incoming !== null ? incoming : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the top-level fields of a partial item, minus every per-type group and
|
||||
* the fields this function owns. Unknown keys pass through: an item written by
|
||||
* a later version must survive a round trip here.
|
||||
*/
|
||||
function stripGroups(fields: Partial<Item>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (GROUP_NAMES.includes(key as ItemTypeName)) continue;
|
||||
if (key === "v" || key === "id") continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "an account", "a login". These strings are read by people. */
|
||||
function article(word: string): string {
|
||||
return /^[aeiou]/i.test(word) ? "an" : "a";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an item carrying a group that is not its own type's.
|
||||
*
|
||||
* A `card` group on a `login` item is either a broken importer or an attempt to
|
||||
* smuggle a field past a type-based permission check; neither should be stored.
|
||||
*/
|
||||
export function assertGroupsMatchType(item: Item): void {
|
||||
for (const name of GROUP_NAMES) {
|
||||
if (name === "note") continue;
|
||||
if (name !== item.type && (item as Record<string, unknown>)[name] !== undefined) {
|
||||
throw new Error(`A ${item.type} item must not carry ${article(name)} ${name} field group`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a password change in the item's own history.
|
||||
*
|
||||
* Called before overwriting the password, so the value being replaced is what
|
||||
* gets kept. Returns a new item; does not mutate.
|
||||
*/
|
||||
export function recordPasswordChange(item: Item, newPassword: string): Item {
|
||||
if (item.type !== "login") throw new Error("Only logins have password history");
|
||||
const previous = item.login?.password ?? "";
|
||||
const history =
|
||||
previous && previous !== newPassword
|
||||
? [{ password: previous, changedAt: new Date().toISOString() }, ...(item.history ?? [])]
|
||||
: [...(item.history ?? [])];
|
||||
|
||||
return {
|
||||
...item,
|
||||
login: { ...(item.login ?? EMPTY_FIELDS.login()), password: newPassword },
|
||||
history: history.slice(0, MAX_HISTORY_ENTRIES),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a partial update, merging the field group rather than replacing it. */
|
||||
export function updateItem(item: Item, patch: Partial<Item>): Item {
|
||||
const group = (patch as Record<string, unknown>)[item.type];
|
||||
const next: Item = {
|
||||
...item,
|
||||
...stripGroups(patch),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (group && typeof group === "object" && item.type !== "note") {
|
||||
(next as Record<string, unknown>)[item.type] = {
|
||||
...((item as Record<string, unknown>)[item.type] as object),
|
||||
...group,
|
||||
};
|
||||
}
|
||||
if (next.history && next.history.length > MAX_HISTORY_ENTRIES) {
|
||||
next.history = next.history.slice(0, MAX_HISTORY_ENTRIES);
|
||||
}
|
||||
assertGroupsMatchType(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The additional authenticated data bound to an item's ciphertext.
|
||||
*
|
||||
* Binding the id means a ciphertext cannot be moved from one row to another
|
||||
* without decryption failing — without it, anyone with database write access
|
||||
* could swap the ciphertext of a low-value login into a high-value one and
|
||||
* watch what the user does next.
|
||||
*/
|
||||
function itemAad(namespace: Namespace, id: string, version: number): Uint8Array {
|
||||
return utf8Encode(itemLabel(namespace, version, id));
|
||||
}
|
||||
|
||||
export async function encryptItem(
|
||||
userKey: Uint8Array,
|
||||
item: Item,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<Envelope> {
|
||||
if (!item?.id) throw new Error("An item must have an id before it can be encrypted");
|
||||
if (!isItemType(item.type)) throw new Error(`Unknown item type: ${item.type}`);
|
||||
assertGroupsMatchType(item);
|
||||
|
||||
const version = item.v ?? ITEM_SCHEMA_VERSION;
|
||||
const plaintext = utf8Encode(JSON.stringify({ ...item, v: version }));
|
||||
const { iv, ciphertext } = await aesGcmEncrypt(userKey, plaintext, itemAad(namespace, item.id, version));
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: ITEM_TYPE[item.type],
|
||||
ciphertext: toBase64(ciphertext),
|
||||
iv: toBase64(iv),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored envelope.
|
||||
*
|
||||
* Throws when the key is wrong, the ciphertext was altered, or the row's id
|
||||
* does not match the one bound at encryption time.
|
||||
*/
|
||||
export async function decryptItem(
|
||||
userKey: Uint8Array,
|
||||
row: Envelope,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<Item> {
|
||||
const version = row.v ?? ITEM_SCHEMA_VERSION;
|
||||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = await aesGcmDecrypt(
|
||||
userKey,
|
||||
fromBase64(row.iv),
|
||||
fromBase64(row.ciphertext),
|
||||
itemAad(namespace, row.id, version),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(`Could not decrypt item ${row.id}`);
|
||||
}
|
||||
|
||||
const item = JSON.parse(utf8Decode(plaintext)) as Item;
|
||||
if (item.id !== row.id) {
|
||||
// Belt and braces: the AAD already makes this unreachable.
|
||||
throw new Error(`Item id mismatch for ${row.id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export interface DecryptResult {
|
||||
items: Item[];
|
||||
failed: Array<{ id: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a page of rows, keeping going when one fails.
|
||||
*
|
||||
* A single corrupt row must not hide the rest of someone's vault, so failures
|
||||
* are collected and returned rather than thrown.
|
||||
*/
|
||||
export async function decryptItems(
|
||||
userKey: Uint8Array,
|
||||
rows: Envelope[],
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<DecryptResult> {
|
||||
const items: Item[] = [];
|
||||
const failed: Array<{ id: string; error: string }> = [];
|
||||
for (const row of rows) {
|
||||
try {
|
||||
items.push(await decryptItem(userKey, row, namespace));
|
||||
} catch (err) {
|
||||
failed.push({ id: row.id, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
return { items, failed };
|
||||
}
|
||||
|
||||
/** The field paths that hold a secret, per type. Used for masking and reveal. */
|
||||
export const SECRET_FIELDS: Readonly<Record<ItemTypeName, readonly string[]>> = Object.freeze({
|
||||
login: ["login.password", "login.totp"],
|
||||
card: ["card.number", "card.code"],
|
||||
identity: ["identity.ssn", "identity.passportNumber", "identity.licenseNumber"],
|
||||
note: [],
|
||||
key: ["key.privateKey", "key.passphrase", "key.value"],
|
||||
account: ["account.accessToken", "account.refreshToken"],
|
||||
});
|
||||
|
||||
/**
|
||||
* A copy of an item with every secret replaced by a mask.
|
||||
*
|
||||
* Used by every display path, including `--json`: a pipeline is not an
|
||||
* authorization, and an item that prints its password when redirected to a file
|
||||
* is an item that leaks into shell history and CI logs.
|
||||
*/
|
||||
export function maskItem(item: Item, mask = "••••••••"): Item {
|
||||
const copy = structuredClone(item) as Item;
|
||||
for (const path of SECRET_FIELDS[item.type] ?? []) {
|
||||
const [group, field] = path.split(".") as [string, string];
|
||||
const holder = (copy as Record<string, unknown>)[group] as Record<string, unknown> | undefined;
|
||||
if (holder && typeof holder[field] === "string" && holder[field] !== "") holder[field] = mask;
|
||||
}
|
||||
if (Array.isArray(copy.fields)) {
|
||||
copy.fields = copy.fields.map((f) => (f.type === "hidden" && f.value ? { ...f, value: mask } : f));
|
||||
}
|
||||
if (Array.isArray(copy.history) && copy.history.length > 0) {
|
||||
copy.history = copy.history.map((h) => ({ ...h, password: mask }));
|
||||
}
|
||||
if (Array.isArray(copy.attachments)) {
|
||||
copy.attachments = copy.attachments.map(({ key, ...rest }) => (key ? { ...rest, key: mask } : rest));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Read one field by dotted path, for a deliberate single-value reveal. */
|
||||
export function readField(item: Item, path: string): string | undefined {
|
||||
const parts = path.split(".");
|
||||
let cursor: unknown = item;
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null) return undefined;
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : cursor === undefined ? undefined : JSON.stringify(cursor);
|
||||
}
|
||||
177
packages/opencreds/src/kdf.ts
Normal file
177
packages/opencreds/src/kdf.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Key derivation.
|
||||
*
|
||||
* The master password is stretched once into a master key, and everything else
|
||||
* is derived from that by HKDF under a distinct label. The labels are prefixed
|
||||
* by the vault's namespace and versioned, because they are baked into every
|
||||
* ciphertext an existing vault has written: a label can be superseded, never
|
||||
* edited.
|
||||
*/
|
||||
|
||||
import { pbkdf2, hkdf, toBase64, KEY_BYTES, MIN_SALT_BYTES } from "./primitives.js";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
NAMESPACE_PATTERN,
|
||||
REGISTERED_NAMESPACES,
|
||||
type KdfName,
|
||||
type KdfParams,
|
||||
type Namespace,
|
||||
} from "./types.js";
|
||||
|
||||
export const KDF = {
|
||||
PBKDF2_SHA256: "pbkdf2-sha256",
|
||||
/**
|
||||
* Reserved. Argon2id needs WASM in the browser, which means adding
|
||||
* 'wasm-unsafe-eval' to an extension CSP — a real cost paid by every user to
|
||||
* benefit the KDF. Parameters are carried per vault specifically so this can
|
||||
* be adopted later without invalidating a single existing vault.
|
||||
*/
|
||||
ARGON2ID: "argon2id",
|
||||
} as const;
|
||||
|
||||
/** OWASP's current floor for PBKDF2-HMAC-SHA256. */
|
||||
export const DEFAULT_KDF_PARAMS: Readonly<KdfParams> = Object.freeze({
|
||||
kdf: KDF.PBKDF2_SHA256 as KdfName,
|
||||
iterations: 600_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* The lowest iteration count a client will accept.
|
||||
*
|
||||
* Parameters arrive from a server, which makes them attacker-controlled the
|
||||
* moment the server is compromised: serving `iterations: 1` would turn every
|
||||
* captured auth hash into an offline guessing exercise with no work factor.
|
||||
* Refuse to derive at all below this rather than silently doing weak work.
|
||||
*/
|
||||
export const MIN_PBKDF2_ITERATIONS = 100_000;
|
||||
|
||||
/** Domain-separation labels. Append-only — supersede, never edit. */
|
||||
export function wrapLabel(namespace: Namespace): string {
|
||||
return `${namespace}:vault:wrap:v1`;
|
||||
}
|
||||
|
||||
export function authLabel(namespace: Namespace): string {
|
||||
return `${namespace}:vault:auth:v1`;
|
||||
}
|
||||
|
||||
export function recoveryLabel(namespace: Namespace): string {
|
||||
return `${namespace}:vault:recovery:v1`;
|
||||
}
|
||||
|
||||
export function itemLabel(namespace: Namespace, version: number, id: string): string {
|
||||
return `${namespace}:vault:item:${version}:${id}`;
|
||||
}
|
||||
|
||||
export function databaseLabel(namespace: Namespace): string {
|
||||
return `${namespace}:database:v1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a namespace.
|
||||
*
|
||||
* Accepting an arbitrary prefix is accepting an arbitrary derivation, so an
|
||||
* unregistered one needs an explicit opt-in rather than a shrug.
|
||||
*/
|
||||
export function assertUsableNamespace(namespace: string, allowUnregistered = false): Namespace {
|
||||
if (!NAMESPACE_PATTERN.test(namespace)) {
|
||||
throw new Error(`Invalid namespace: ${JSON.stringify(namespace)}`);
|
||||
}
|
||||
if (!allowUnregistered && !REGISTERED_NAMESPACES.includes(namespace)) {
|
||||
throw new Error(
|
||||
`Unregistered namespace "${namespace}" — registered namespaces are ${REGISTERED_NAMESPACES.join(", ")}. ` +
|
||||
"Pass allowUnregistered to open it anyway.",
|
||||
);
|
||||
}
|
||||
return namespace;
|
||||
}
|
||||
|
||||
/** Validate KDF parameters received from a server, or read from a file. */
|
||||
export function assertUsableKdfParams(params: Partial<KdfParams> | undefined): KdfParams {
|
||||
const kdf = params?.kdf;
|
||||
if (kdf !== KDF.PBKDF2_SHA256) {
|
||||
throw new Error(
|
||||
kdf === KDF.ARGON2ID
|
||||
? "argon2id is registered but not implemented; refusing to fall back to a weaker KDF"
|
||||
: `Unsupported KDF: ${kdf ?? "missing"}`,
|
||||
);
|
||||
}
|
||||
const iterations = params?.iterations;
|
||||
if (!Number.isInteger(iterations) || (iterations as number) < MIN_PBKDF2_ITERATIONS) {
|
||||
throw new Error(
|
||||
`Refusing to derive a key with ${iterations} iterations — the minimum is ${MIN_PBKDF2_ITERATIONS}`,
|
||||
);
|
||||
}
|
||||
return { kdf, iterations: iterations as number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stretch the master password into the master key.
|
||||
*
|
||||
* The master key never encrypts anything directly; it exists only to be split
|
||||
* by the derivations below.
|
||||
*/
|
||||
export async function deriveMasterKey(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
params: KdfParams = DEFAULT_KDF_PARAMS,
|
||||
): Promise<Uint8Array> {
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
throw new Error("A master password is required");
|
||||
}
|
||||
if (!(salt instanceof Uint8Array) || salt.length < MIN_SALT_BYTES) {
|
||||
throw new Error(`KDF salt must be at least ${MIN_SALT_BYTES} bytes`);
|
||||
}
|
||||
const { iterations } = assertUsableKdfParams(params);
|
||||
return pbkdf2(password, salt, iterations, KEY_BYTES);
|
||||
}
|
||||
|
||||
/** The key that wraps the user key. Never leaves the device. */
|
||||
export function deriveWrapKey(masterKey: Uint8Array, namespace: Namespace = DEFAULT_NAMESPACE): Promise<Uint8Array> {
|
||||
return hkdf(masterKey, wrapLabel(namespace), KEY_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* The only password-derived value that may leave the device. Because it comes
|
||||
* out of a different HKDF label than the wrapping key, holding it does not help
|
||||
* an attacker decrypt anything. A server storing it hashes it again.
|
||||
*/
|
||||
export async function deriveAuthHash(
|
||||
masterKey: Uint8Array,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<string> {
|
||||
return toBase64(await hkdf(masterKey, authLabel(namespace), KEY_BYTES));
|
||||
}
|
||||
|
||||
/** The key that wraps the recovery copy of the user key. */
|
||||
export function deriveRecoveryWrapKey(
|
||||
recoveryKey: Uint8Array,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<Uint8Array> {
|
||||
return hkdf(recoveryKey, recoveryLabel(namespace), KEY_BYTES);
|
||||
}
|
||||
|
||||
/** The key an export is encrypted under — derived from the export passphrase, not the vault. */
|
||||
export async function deriveExportKey(
|
||||
passphrase: string,
|
||||
salt: Uint8Array,
|
||||
params: KdfParams = DEFAULT_KDF_PARAMS,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<Uint8Array> {
|
||||
const master = await deriveMasterKey(passphrase, salt, params);
|
||||
return hkdf(master, databaseLabel(namespace), KEY_BYTES);
|
||||
}
|
||||
|
||||
/** Derive both halves at once — the common path on unlock. */
|
||||
export async function deriveAll(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
params: KdfParams = DEFAULT_KDF_PARAMS,
|
||||
namespace: Namespace = DEFAULT_NAMESPACE,
|
||||
): Promise<{ masterKey: Uint8Array; wrapKey: Uint8Array; authHash: string }> {
|
||||
const masterKey = await deriveMasterKey(password, salt, params);
|
||||
const [wrapKey, authHash] = await Promise.all([
|
||||
deriveWrapKey(masterKey, namespace),
|
||||
deriveAuthHash(masterKey, namespace),
|
||||
]);
|
||||
return { masterKey, wrapKey, authHash };
|
||||
}
|
||||
161
packages/opencreds/src/primitives.ts
Normal file
161
packages/opencreds/src/primitives.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Cryptographic primitives, over WebCrypto only.
|
||||
*
|
||||
* WebCrypto rather than node:crypto because the same code has to run in a
|
||||
* browser extension's service worker, in a page, and in a CLI. Anything here
|
||||
* that reached for a Node built-in would fork the implementation at exactly the
|
||||
* layer where a fork is most expensive to verify.
|
||||
*/
|
||||
|
||||
/** AES-GCM IV length. 96 bits is the size GCM is specified and fastest for. */
|
||||
export const IV_BYTES = 12;
|
||||
|
||||
/** Symmetric key length. 256-bit AES throughout. */
|
||||
export const KEY_BYTES = 32;
|
||||
|
||||
/** Minimum KDF salt. Anything shorter stops being a salt. */
|
||||
export const MIN_SALT_BYTES = 16;
|
||||
|
||||
function subtle(): SubtleCrypto {
|
||||
const c = globalThis.crypto;
|
||||
if (!c?.subtle) {
|
||||
throw new Error("WebCrypto is unavailable; OpenCreds requires globalThis.crypto.subtle");
|
||||
}
|
||||
return c.subtle;
|
||||
}
|
||||
|
||||
/** Cryptographically secure random bytes. */
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
const out = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function utf8Encode(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
export function utf8Decode(bytes: Uint8Array): string {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function fromBase64(value: string): Uint8Array {
|
||||
const binary = atob(value);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function fromHex(value: string): Uint8Array {
|
||||
const clean = value.replace(/[^0-9a-fA-F]/g, "");
|
||||
const out = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison.
|
||||
*
|
||||
* Used on auth hashes and any other secret-derived value. Everything else in
|
||||
* the format compares public data, where a fast path is fine.
|
||||
*/
|
||||
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** A UUID, from the platform's own generator. */
|
||||
export function uuid(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
function bufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
// A Uint8Array view over a larger buffer would otherwise hand WebCrypto the
|
||||
// whole buffer. Copy defensively; these are small.
|
||||
return bytes.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** PBKDF2-HMAC-SHA256. The only deliberately expensive operation in the format. */
|
||||
export async function pbkdf2(
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
iterations: number,
|
||||
length = KEY_BYTES,
|
||||
): Promise<Uint8Array> {
|
||||
const material = await subtle().importKey("raw", bufferSource(utf8Encode(password)), "PBKDF2", false, [
|
||||
"deriveBits",
|
||||
]);
|
||||
const bits = await subtle().deriveBits(
|
||||
{ name: "PBKDF2", salt: bufferSource(salt), iterations, hash: "SHA-256" },
|
||||
material,
|
||||
length * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF-SHA256 with an empty salt.
|
||||
*
|
||||
* The guarantee being used is that outputs under distinct `info` strings are
|
||||
* computationally independent — which is what lets the auth hash be sent to a
|
||||
* server without helping anyone derive the wrapping key.
|
||||
*/
|
||||
export async function hkdf(key: Uint8Array, info: string, length = KEY_BYTES): Promise<Uint8Array> {
|
||||
const material = await subtle().importKey("raw", bufferSource(key), "HKDF", false, ["deriveBits"]);
|
||||
const bits = await subtle().deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: bufferSource(utf8Encode(info)) },
|
||||
material,
|
||||
length * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export async function sha256(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
return new Uint8Array(await subtle().digest("SHA-256", bufferSource(bytes)));
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM.
|
||||
*
|
||||
* The IV is generated here, per call, and never accepted from a caller. With
|
||||
* GCM a repeated IV under one key is not a weakness but a break — it leaks the
|
||||
* XOR of two plaintexts along with the authentication subkey — and the only
|
||||
* reliable way to prevent an accidental reuse is to remove the opportunity.
|
||||
*/
|
||||
export async function aesGcmEncrypt(
|
||||
key: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
aad?: Uint8Array,
|
||||
): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cryptoKey = await subtle().importKey("raw", bufferSource(key), "AES-GCM", false, ["encrypt"]);
|
||||
const params: AesGcmParams = { name: "AES-GCM", iv: bufferSource(iv) };
|
||||
if (aad) params.additionalData = bufferSource(aad);
|
||||
const ciphertext = await subtle().encrypt(params, cryptoKey, bufferSource(plaintext));
|
||||
return { iv, ciphertext: new Uint8Array(ciphertext) };
|
||||
}
|
||||
|
||||
export async function aesGcmDecrypt(
|
||||
key: Uint8Array,
|
||||
iv: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
aad?: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const cryptoKey = await subtle().importKey("raw", bufferSource(key), "AES-GCM", false, ["decrypt"]);
|
||||
const params: AesGcmParams = { name: "AES-GCM", iv: bufferSource(iv) };
|
||||
if (aad) params.additionalData = bufferSource(aad);
|
||||
const plaintext = await subtle().decrypt(params, cryptoKey, bufferSource(ciphertext));
|
||||
return new Uint8Array(plaintext);
|
||||
}
|
||||
89
packages/opencreds/src/prompt.ts
Normal file
89
packages/opencreds/src/prompt.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Terminal input for secrets.
|
||||
*
|
||||
* A master password must not appear in the shell history, the process list, or
|
||||
* the terminal scrollback, which rules out an argument, an environment variable
|
||||
* and an unmuted read. So: read from the TTY with echo off, and offer stdin for
|
||||
* the scripted case.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { stdin, stdout } from "node:process";
|
||||
|
||||
/** Read a line with the terminal's echo turned off. */
|
||||
export async function promptSecret(label: string): Promise<string> {
|
||||
if (!stdin.isTTY) {
|
||||
// Not a terminal: read one line from stdin instead of failing. This is the
|
||||
// `echo … | opencreds …` path, and it is why every secret flag accepts `-`.
|
||||
return readLineFromStdin();
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: stdin, output: stdout, terminal: true });
|
||||
const asMutable = rl as unknown as { output: { write: (chunk: string) => void }; _writeToOutput?: (s: string) => void };
|
||||
|
||||
let muted = false;
|
||||
asMutable._writeToOutput = function write(chunk: string): void {
|
||||
if (!muted) {
|
||||
asMutable.output.write(chunk);
|
||||
return;
|
||||
}
|
||||
// Echo nothing at all rather than asterisks: a length is information, and
|
||||
// it is the one piece of a password an observer gets for free otherwise.
|
||||
if (chunk.includes("\n")) asMutable.output.write("\n");
|
||||
};
|
||||
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
rl.question(label, (value) => resolve(value));
|
||||
muted = true;
|
||||
});
|
||||
muted = false;
|
||||
rl.close();
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** Ask twice and require agreement. A typo'd master password is an empty vault. */
|
||||
export async function promptNewSecret(label: string, confirmLabel = "Repeat: "): Promise<string> {
|
||||
const first = await promptSecret(label);
|
||||
if (first.length === 0) throw new Error("A password is required");
|
||||
const second = await promptSecret(confirmLabel);
|
||||
if (first !== second) throw new Error("The two entries did not match");
|
||||
return first;
|
||||
}
|
||||
|
||||
export async function promptLine(label: string): Promise<string> {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
const answer = await new Promise<string>((resolve) => rl.question(label, resolve));
|
||||
rl.close();
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** A yes/no gate. Anything but an explicit yes is a no. */
|
||||
export async function confirm(question: string): Promise<boolean> {
|
||||
if (!stdin.isTTY) return false;
|
||||
const answer = await promptLine(`${question} [y/N] `);
|
||||
return /^y(es)?$/i.test(answer.trim());
|
||||
}
|
||||
|
||||
export function readLineFromStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
stdin.setEncoding("utf8");
|
||||
stdin.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
stdin.on("end", () => resolve(data.replace(/\r?\n$/, "")));
|
||||
stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a flag value that may be `-`, meaning "read it from stdin".
|
||||
*
|
||||
* Every secret-bearing flag goes through here, so a secret need never appear in
|
||||
* an argument vector that `ps` will happily print to anyone on the box.
|
||||
*/
|
||||
export async function resolveSecretFlag(value: string | undefined): Promise<string | undefined> {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "-") return readLineFromStdin();
|
||||
return value;
|
||||
}
|
||||
97
packages/opencreds/src/session.ts
Normal file
97
packages/opencreds/src/session.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Unlock sessions.
|
||||
*
|
||||
* Two shapes, and the difference matters enough to be a flag rather than a
|
||||
* default:
|
||||
*
|
||||
* - **Token (default).** `unlock` prints a session token; the shell exports it
|
||||
* as OPENCREDS_SESSION and it lives in that process's environment. Nothing
|
||||
* touches disk, and it dies with the shell.
|
||||
*
|
||||
* - **Persisted (`--persist`).** The same token in a 0600 file with an expiry,
|
||||
* so a script can unlock once and run many commands. This is a real cost: a
|
||||
* readable user key on disk is the vault. It is opt-in, it says so when you
|
||||
* use it, and `lock` removes it.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fromBase64, toBase64 } from "./primitives.js";
|
||||
import { opencredsHome } from "./store.js";
|
||||
|
||||
export const SESSION_ENV = "OPENCREDS_SESSION";
|
||||
|
||||
interface SessionFile {
|
||||
key: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
function sessionPath(baseDir: string): string {
|
||||
return join(baseDir, "session.json");
|
||||
}
|
||||
|
||||
/** The session token for a user key — base64, and exactly as sensitive as the key. */
|
||||
export function encodeSession(userKey: Uint8Array): string {
|
||||
return toBase64(userKey);
|
||||
}
|
||||
|
||||
export function decodeSession(token: string): Uint8Array {
|
||||
const key = fromBase64(token.trim());
|
||||
if (key.length !== 32) throw new Error("Invalid session token");
|
||||
return key;
|
||||
}
|
||||
|
||||
export function persistSession(userKey: Uint8Array, minutes: number, baseDir = opencredsHome()): string {
|
||||
const path = sessionPath(baseDir);
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const file: SessionFile = {
|
||||
key: encodeSession(userKey),
|
||||
expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
|
||||
};
|
||||
writeFileSync(path, `${JSON.stringify(file)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// No modes on this platform; the write still happened.
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function clearSession(baseDir = opencredsHome()): boolean {
|
||||
const path = sessionPath(baseDir);
|
||||
if (!existsSync(path)) return false;
|
||||
rmSync(path, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user key for this invocation, if there is one.
|
||||
*
|
||||
* The environment wins over the file: an explicitly exported session is a
|
||||
* deliberate act, and a stale file should never silently override it.
|
||||
*/
|
||||
export function readSession(baseDir = opencredsHome()): Uint8Array | undefined {
|
||||
const fromEnv = process.env[SESSION_ENV];
|
||||
if (fromEnv && fromEnv.trim() !== "") {
|
||||
try {
|
||||
return decodeSession(fromEnv);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const path = sessionPath(baseDir);
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const file = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
if (new Date(file.expiresAt).getTime() < Date.now()) {
|
||||
// Expired sessions are removed on read rather than left to rot: the file
|
||||
// is the risk, and a session nobody can use is pure risk.
|
||||
rmSync(path, { force: true });
|
||||
return undefined;
|
||||
}
|
||||
return decodeSession(file.key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
158
packages/opencreds/src/store.ts
Normal file
158
packages/opencreds/src/store.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* A file-backed vault, so the CLI has somewhere to keep items between
|
||||
* invocations.
|
||||
*
|
||||
* Layout under `$OPENCREDS_HOME` or `~/.config/logicsrc/opencreds`:
|
||||
*
|
||||
* meta.json vault metadata — key material, all of it wrapped
|
||||
* items/<id>.json one envelope per item
|
||||
* audit.jsonl append-only audit events, values never present
|
||||
*
|
||||
* One file per item rather than one file for the vault, for the same reason
|
||||
* storage-backed implementations use one row per item: two writers editing two
|
||||
* different passwords must not cost anyone a credential, and with a single blob
|
||||
* the later write silently discards the earlier.
|
||||
*
|
||||
* The store never sees a key. It reads and writes ciphertext; unlocking happens
|
||||
* in the caller and the user key stays in that caller's memory.
|
||||
*/
|
||||
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AuditEvent, Envelope, Folder, VaultMeta } from "./types.js";
|
||||
|
||||
export function opencredsHome(): string {
|
||||
const override = process.env.OPENCREDS_HOME;
|
||||
if (override && override.trim() !== "") return override;
|
||||
const config = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
||||
return join(config, "logicsrc", "opencreds");
|
||||
}
|
||||
|
||||
/** 0600, always. The one place vault bytes touch this machine's disk. */
|
||||
function writePrivate(path: string, contents: string): void {
|
||||
writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Windows and some network filesystems have no modes. The write still
|
||||
// happened; a missing chmod is not a reason to lose the data.
|
||||
}
|
||||
}
|
||||
|
||||
export interface VaultStore {
|
||||
baseDir: string;
|
||||
exists(): boolean;
|
||||
readMeta(): VaultMeta | undefined;
|
||||
writeMeta(meta: VaultMeta): void;
|
||||
listEnvelopes(): Envelope[];
|
||||
readEnvelope(id: string): Envelope | undefined;
|
||||
writeEnvelope(envelope: Envelope): void;
|
||||
deleteEnvelope(id: string): void;
|
||||
readFolders(): Folder[];
|
||||
writeFolders(folders: Folder[]): void;
|
||||
appendAudit(event: AuditEvent): void;
|
||||
readAudit(): AuditEvent[];
|
||||
}
|
||||
|
||||
export function createVaultStore(baseDir = opencredsHome()): VaultStore {
|
||||
const itemsDir = join(baseDir, "items");
|
||||
const metaPath = join(baseDir, "meta.json");
|
||||
const foldersPath = join(baseDir, "folders.json");
|
||||
const auditPath = join(baseDir, "audit.jsonl");
|
||||
|
||||
function ensureDirs(): void {
|
||||
mkdirSync(itemsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T | undefined {
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseDir,
|
||||
|
||||
exists(): boolean {
|
||||
return existsSync(metaPath);
|
||||
},
|
||||
|
||||
readMeta(): VaultMeta | undefined {
|
||||
return readJson<VaultMeta>(metaPath);
|
||||
},
|
||||
|
||||
writeMeta(meta: VaultMeta): void {
|
||||
ensureDirs();
|
||||
writePrivate(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
|
||||
},
|
||||
|
||||
listEnvelopes(): Envelope[] {
|
||||
if (!existsSync(itemsDir)) return [];
|
||||
const out: Envelope[] = [];
|
||||
for (const file of readdirSync(itemsDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const envelope = readJson<Envelope>(join(itemsDir, file));
|
||||
if (envelope) out.push(envelope);
|
||||
}
|
||||
// Stable order, so two runs of `list` agree and a diff of two exports is
|
||||
// about the vault rather than about the filesystem.
|
||||
return out.sort((a, b) => a.id.localeCompare(b.id));
|
||||
},
|
||||
|
||||
readEnvelope(id: string): Envelope | undefined {
|
||||
return readJson<Envelope>(join(itemsDir, `${id}.json`));
|
||||
},
|
||||
|
||||
writeEnvelope(envelope: Envelope): void {
|
||||
ensureDirs();
|
||||
const existing = readJson<Envelope>(join(itemsDir, `${envelope.id}.json`));
|
||||
const now = new Date().toISOString();
|
||||
const next: Envelope = {
|
||||
...envelope,
|
||||
revision: (existing?.revision ?? 0) + 1,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
writePrivate(join(itemsDir, `${envelope.id}.json`), `${JSON.stringify(next, null, 2)}\n`);
|
||||
},
|
||||
|
||||
deleteEnvelope(id: string): void {
|
||||
rmSync(join(itemsDir, `${id}.json`), { force: true });
|
||||
},
|
||||
|
||||
readFolders(): Folder[] {
|
||||
return readJson<Folder[]>(foldersPath) ?? [];
|
||||
},
|
||||
|
||||
writeFolders(folders: Folder[]): void {
|
||||
ensureDirs();
|
||||
writePrivate(foldersPath, `${JSON.stringify(folders, null, 2)}\n`);
|
||||
},
|
||||
|
||||
appendAudit(event: AuditEvent): void {
|
||||
ensureDirs();
|
||||
// Append rather than rewrite: an audit trail that is rewritten on every
|
||||
// event is an audit trail a crash can truncate to nothing.
|
||||
const line = `${JSON.stringify(event)}\n`;
|
||||
writeFileSync(auditPath, line, { encoding: "utf8", flag: "a", mode: 0o600 });
|
||||
},
|
||||
|
||||
readAudit(): AuditEvent[] {
|
||||
if (!existsSync(auditPath)) return [];
|
||||
return readFileSync(auditPath, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "")
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as AuditEvent];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
339
packages/opencreds/src/types.ts
Normal file
339
packages/opencreds/src/types.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/**
|
||||
* OpenCreds 0.1 — the record, the vault, and the portable database.
|
||||
*
|
||||
* The specification these types implement is `docs/opencreds/spec.md`. Where a
|
||||
* comment here explains *why* a shape is what it is, the normative statement is
|
||||
* in the spec; this file is the executable half.
|
||||
*/
|
||||
|
||||
/** The version stamped into every item and every database. */
|
||||
export const OPENCREDS_VERSION = "0.1" as const;
|
||||
|
||||
/**
|
||||
* Item type names, and the integer codes an implementation may store in
|
||||
* plaintext beside the ciphertext so a server can filter and paginate without
|
||||
* decrypting.
|
||||
*
|
||||
* Codes 1-4 are fixed by a deployed vault (MarkSyncr) and MUST NOT be
|
||||
* renumbered; 5 and 6 are introduced by OpenCreds. Compatibility is cheaper
|
||||
* than elegance.
|
||||
*/
|
||||
export const ITEM_TYPE = Object.freeze({
|
||||
login: 1,
|
||||
card: 2,
|
||||
identity: 3,
|
||||
note: 4,
|
||||
key: 5,
|
||||
account: 6,
|
||||
});
|
||||
|
||||
export type ItemTypeName = keyof typeof ITEM_TYPE;
|
||||
|
||||
/** Reverse lookup, for turning a stored row back into a name. */
|
||||
export const ITEM_TYPE_NAME: Readonly<Record<number, ItemTypeName>> = Object.freeze(
|
||||
Object.fromEntries(Object.entries(ITEM_TYPE).map(([name, id]) => [id, name])) as Record<number, ItemTypeName>,
|
||||
);
|
||||
|
||||
export const ITEM_TYPE_NAMES: readonly ItemTypeName[] = Object.freeze(
|
||||
Object.keys(ITEM_TYPE) as ItemTypeName[],
|
||||
);
|
||||
|
||||
/** Item schema version. A record you cannot identify is a record you cannot migrate. */
|
||||
export const ITEM_SCHEMA_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Password history cap. The item blob is rewritten in full on every save, so an
|
||||
* uncapped array grows the ciphertext without bound — and the growth is
|
||||
* invisible until a sync starts timing out.
|
||||
*/
|
||||
export const MAX_HISTORY_ENTRIES = 20;
|
||||
|
||||
/** How a stored URI is matched when a client decides where to offer a credential. */
|
||||
export type UriMatch = "domain" | "host" | "startsWith" | "exact" | "regex" | "never";
|
||||
|
||||
export interface ItemUri {
|
||||
uri: string;
|
||||
match?: UriMatch;
|
||||
}
|
||||
|
||||
export interface LoginGroup {
|
||||
username: string;
|
||||
password: string;
|
||||
/** An `otpauth://` URI where available — a bare seed loses algorithm, digits and period. */
|
||||
totp: string;
|
||||
uris: ItemUri[];
|
||||
}
|
||||
|
||||
export interface CardGroup {
|
||||
cardholderName: string;
|
||||
brand: string;
|
||||
number: string;
|
||||
expMonth: string;
|
||||
expYear: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface IdentityGroup {
|
||||
title: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
username: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
address3: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
/** National identity number. Named `ssn` for import compatibility; not US-specific. */
|
||||
ssn: string;
|
||||
passportNumber: string;
|
||||
licenseNumber: string;
|
||||
}
|
||||
|
||||
export type KeyKind = "ssh" | "pgp" | "api" | "symmetric" | "certificate" | "env";
|
||||
|
||||
export interface KeyGroup {
|
||||
keyType: KeyKind | "";
|
||||
algorithm: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
passphrase: string;
|
||||
/** `SHA256:…` — a public, non-secret identifier. */
|
||||
fingerprint: string;
|
||||
/** The secret for key types that are one opaque string (api, env, symmetric). */
|
||||
value: string;
|
||||
/** Where the key belongs on disk. A key at the wrong path is a key nothing finds. */
|
||||
path: string;
|
||||
/** POSIX mode, octal. A private key restored 0644 is a key ssh refuses to use. */
|
||||
mode: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface AccountGroup {
|
||||
provider: string;
|
||||
accountId: string;
|
||||
handle: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
tokenType: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
/** production, sandbox, … A test key and a live key look identical and are not. */
|
||||
environment: string;
|
||||
}
|
||||
|
||||
export type FieldKind = "text" | "hidden" | "boolean" | "linked";
|
||||
|
||||
export interface CustomField {
|
||||
name: string;
|
||||
value: string;
|
||||
type: FieldKind;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface AttachmentRef {
|
||||
id: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
digest?: string;
|
||||
/** Base64 AES key, held inside the item ciphertext so the blob store never sees it. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
password: string;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One credential record.
|
||||
*
|
||||
* The index signature is what makes §3.1's "preserve unknown fields" rule
|
||||
* implementable: an item written by a later version passes through this one
|
||||
* without losing what it did not understand.
|
||||
*/
|
||||
export interface Item {
|
||||
v: number;
|
||||
id: string;
|
||||
type: ItemTypeName;
|
||||
name: string;
|
||||
favorite?: boolean;
|
||||
folderId?: string | null;
|
||||
notes?: string;
|
||||
fields?: CustomField[];
|
||||
attachments?: AttachmentRef[];
|
||||
history?: HistoryEntry[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
login?: LoginGroup;
|
||||
card?: CardGroup;
|
||||
identity?: IdentityGroup;
|
||||
key?: KeyGroup;
|
||||
account?: AccountGroup;
|
||||
[unknown: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** An encrypted item as it is stored. Only id and type are plaintext. */
|
||||
export interface Envelope {
|
||||
id: string;
|
||||
type: number;
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
v?: number;
|
||||
revision?: number;
|
||||
deletedAt?: string | null;
|
||||
purgeAfter?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain-separation label prefix for a vault.
|
||||
*
|
||||
* Carried as data because labels are compiled into the AAD of every ciphertext
|
||||
* a vault has ever written. Editing one does not migrate a vault; it makes it
|
||||
* undecryptable.
|
||||
*/
|
||||
export type Namespace = string;
|
||||
|
||||
export const DEFAULT_NAMESPACE = "opencreds";
|
||||
export const REGISTERED_NAMESPACES: readonly string[] = Object.freeze(["opencreds", "marksyncr"]);
|
||||
export const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]{1,31}$/;
|
||||
|
||||
/** How the user key is managed. The item envelope is identical under both. */
|
||||
export type Profile = "user" | "team";
|
||||
|
||||
export type KdfName = "pbkdf2-sha256" | "argon2id";
|
||||
|
||||
export interface KdfParams {
|
||||
kdf: KdfName;
|
||||
iterations: number;
|
||||
memoryKib?: number;
|
||||
parallelism?: number;
|
||||
}
|
||||
|
||||
export interface VaultMeta {
|
||||
opencreds: typeof OPENCREDS_VERSION;
|
||||
namespace: Namespace;
|
||||
profile: Profile;
|
||||
kdf: KdfName;
|
||||
kdfIterations: number;
|
||||
kdfMemoryKib?: number;
|
||||
kdfParallelism?: number;
|
||||
kdfSalt: string;
|
||||
protectedUserKey: string;
|
||||
protectedUserKeyIv: string;
|
||||
recoveryKeyBlob?: string;
|
||||
recoveryKeyIv?: string;
|
||||
authHash?: string;
|
||||
wrappedKeys?: Array<{ memberId: string; publicKey: string; wrappedKey: string; grantedAt?: string }>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseManifest {
|
||||
itemCount: number;
|
||||
types: Partial<Record<ItemTypeName, number>>;
|
||||
folderCount: number;
|
||||
/** Base64 SHA-256 over sorted item ids joined by "\n". */
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface DatabaseHeader {
|
||||
opencreds: typeof OPENCREDS_VERSION;
|
||||
type: "opencreds.database";
|
||||
protected: boolean;
|
||||
namespace: Namespace;
|
||||
exportedAt: string;
|
||||
generator?: { name: string; version: string };
|
||||
kdf?: { kdf: KdfName; iterations: number; salt: string };
|
||||
manifest: DatabaseManifest;
|
||||
}
|
||||
|
||||
export interface EncryptedDatabase extends DatabaseHeader {
|
||||
protected: true;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface PlaintextDatabase extends DatabaseHeader {
|
||||
protected: false;
|
||||
folders: Folder[];
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
export type Database = EncryptedDatabase | PlaintextDatabase;
|
||||
|
||||
/** The payload a database encrypts, and what a plaintext one carries inline. */
|
||||
export interface DatabasePayload {
|
||||
folders: Folder[];
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
/** How an import resolves an id that already exists. */
|
||||
export type MergeStrategy = "skip" | "replace" | "duplicate";
|
||||
|
||||
export interface ImportOutcome {
|
||||
added: number;
|
||||
replaced: number;
|
||||
duplicated: number;
|
||||
skipped: number;
|
||||
foldersAdded: number;
|
||||
foldersMerged: number;
|
||||
}
|
||||
|
||||
export interface SkippedRow {
|
||||
row: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ParsedImport {
|
||||
source: string | null;
|
||||
items: Item[];
|
||||
folders: Folder[];
|
||||
skipped: SkippedRow[];
|
||||
}
|
||||
|
||||
export type AuditAction =
|
||||
| "vault.create"
|
||||
| "vault.unlock"
|
||||
| "vault.unlock_failed"
|
||||
| "vault.rekey"
|
||||
| "vault.recovery_reset"
|
||||
| "item.create"
|
||||
| "item.update"
|
||||
| "item.delete"
|
||||
| "item.restore"
|
||||
| "item.purge"
|
||||
| "database.export"
|
||||
| "database.export_plaintext"
|
||||
| "database.import";
|
||||
|
||||
export interface AuditEvent {
|
||||
type: "opencreds.audit_event";
|
||||
id: string;
|
||||
action: AuditAction;
|
||||
itemId?: string;
|
||||
itemType?: ItemTypeName;
|
||||
namespace?: Namespace;
|
||||
profile?: Profile;
|
||||
principal?: { kind?: "user" | "agent" | "service"; id?: string; label?: string };
|
||||
fingerprint?: string;
|
||||
itemCount?: number;
|
||||
dryRun?: boolean;
|
||||
outcome?: "succeeded" | "failed" | "refused";
|
||||
reason?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
153
packages/opencreds/src/validate.test.ts
Normal file
153
packages/opencreds/src/validate.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createItem, formatDiagnostics, hasErrors, validateDatabase, validateDocument, validateItem } from "./index.js";
|
||||
import type { Item } from "./index.js";
|
||||
|
||||
function pointers(diagnostics: ReturnType<typeof validateItem>): string[] {
|
||||
return diagnostics.map((d) => d.pointer);
|
||||
}
|
||||
|
||||
describe("validating an item", () => {
|
||||
it("accepts every well-formed type", () => {
|
||||
for (const type of ["login", "card", "identity", "note", "key", "account"] as const) {
|
||||
expect(validateItem(createItem(type, { name: type }))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("points at the field that is wrong", () => {
|
||||
const diagnostics = validateItem({ ...createItem("login"), id: "not-a-uuid" });
|
||||
expect(pointers(diagnostics)).toContain("/id");
|
||||
expect(diagnostics[0]?.message).toMatch(/bound into the ciphertext/);
|
||||
});
|
||||
|
||||
it("names a bad URI match rule with its index", () => {
|
||||
const item = createItem("login", {
|
||||
login: { username: "", password: "", totp: "", uris: [{ uri: "https://x" }, { uri: "https://y", match: "fuzzy" }] },
|
||||
} as unknown as Partial<Item>);
|
||||
expect(pointers(validateItem(item))).toEqual(["/login/uris/1/match"]);
|
||||
});
|
||||
|
||||
it("rejects a group belonging to another type", () => {
|
||||
const item = { ...createItem("login"), card: { number: "1" } } as unknown as Item;
|
||||
expect(pointers(validateItem(item))).toContain("/card");
|
||||
});
|
||||
|
||||
it("rejects history on a non-login and an over-long history on a login", () => {
|
||||
const note = { ...createItem("note"), history: [{ password: "x", changedAt: new Date().toISOString() }] } as Item;
|
||||
expect(pointers(validateItem(note))).toContain("/history");
|
||||
|
||||
const long = {
|
||||
...createItem("login"),
|
||||
history: Array.from({ length: 21 }, () => ({ password: "x", changedAt: new Date().toISOString() })),
|
||||
} as Item;
|
||||
expect(validateItem(long).some((d) => d.message.includes("capped at 20"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a non-octal key mode and an unknown key type", () => {
|
||||
const item = createItem("key", { key: { keyType: "quantum", mode: "rwx" } } as unknown as Partial<Item>);
|
||||
expect(pointers(validateItem(item)).sort()).toEqual(["/key/keyType", "/key/mode"]);
|
||||
});
|
||||
|
||||
it("accepts an octal mode with or without a leading zero", () => {
|
||||
for (const mode of ["600", "0600", "0644"]) {
|
||||
expect(validateItem(createItem("key", { key: { mode } } as unknown as Partial<Item>))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("checks custom field shapes", () => {
|
||||
const item = createItem("note", { fields: [{ name: "a", value: "b", type: "mystery" }] } as unknown as Partial<Item>);
|
||||
expect(pointers(validateItem(item))).toEqual(["/fields/0/type"]);
|
||||
});
|
||||
|
||||
it("prefixes pointers with the position it was given", () => {
|
||||
expect(pointers(validateItem({ ...createItem("login"), id: "x" }, "/items/17"))).toContain("/items/17/id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validating a database", () => {
|
||||
const base = {
|
||||
opencreds: "0.1",
|
||||
type: "opencreds.database",
|
||||
protected: false,
|
||||
namespace: "opencreds",
|
||||
exportedAt: "2026-08-29T00:00:00.000Z",
|
||||
manifest: { itemCount: 0, types: {}, folderCount: 0, digest: "x" },
|
||||
items: [] as Item[],
|
||||
};
|
||||
|
||||
it("accepts a well-formed plaintext database, with a warning about what it is", () => {
|
||||
const diagnostics = validateDatabase(base);
|
||||
expect(hasErrors(diagnostics)).toBe(false);
|
||||
expect(diagnostics.some((d) => d.severity === "warning" && d.pointer === "/protected")).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a manifest", () => {
|
||||
const { manifest, ...without } = base;
|
||||
void manifest;
|
||||
expect(pointers(validateDatabase(without))).toContain("/manifest");
|
||||
});
|
||||
|
||||
it("rejects an encrypted database that also states its items in the clear", () => {
|
||||
const diagnostics = validateDatabase({ ...base, protected: true, iv: "x", ciphertext: "y", items: [] });
|
||||
expect(pointers(diagnostics)).toContain("/items");
|
||||
});
|
||||
|
||||
it("rejects an export kdf below the floor", () => {
|
||||
const diagnostics = validateDatabase({
|
||||
...base,
|
||||
protected: true,
|
||||
iv: "x",
|
||||
ciphertext: "y",
|
||||
items: undefined,
|
||||
kdf: { kdf: "pbkdf2-sha256", iterations: 10, salt: "s" },
|
||||
});
|
||||
expect(pointers(diagnostics)).toContain("/kdf/iterations");
|
||||
});
|
||||
|
||||
it("warns rather than errors on an unregistered namespace", () => {
|
||||
const diagnostics = validateDatabase({ ...base, namespace: "someone-else" });
|
||||
expect(hasErrors(diagnostics)).toBe(false);
|
||||
expect(diagnostics.some((d) => d.pointer === "/namespace" && d.severity === "warning")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a namespace that is not a namespace", () => {
|
||||
const diagnostics = validateDatabase({ ...base, namespace: "Not A Namespace" });
|
||||
expect(hasErrors(diagnostics)).toBe(true);
|
||||
});
|
||||
|
||||
it("validates the items inside a plaintext database, with their positions", () => {
|
||||
const diagnostics = validateDatabase({
|
||||
...base,
|
||||
items: [createItem("login"), { ...createItem("login"), id: "nope" } as Item],
|
||||
});
|
||||
expect(pointers(diagnostics)).toContain("/items/1/id");
|
||||
});
|
||||
|
||||
it("rejects an unsupported version", () => {
|
||||
expect(pointers(validateDatabase({ ...base, opencreds: "9.9" }))).toContain("/opencreds");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDocument", () => {
|
||||
it("recognises a database, an item, and a bare array of items", () => {
|
||||
expect(validateDocument({ ...{ type: "opencreds.database" } }).kind).toBe("database");
|
||||
expect(validateDocument(createItem("login")).kind).toBe("item");
|
||||
expect(validateDocument([createItem("login")]).kind).toBe("items");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting", () => {
|
||||
it("aligns the pointers and marks warnings", () => {
|
||||
const rendered = formatDiagnostics([
|
||||
{ pointer: "/items/17/login/uris/0/match", message: '"fuzzy" is not a valid match rule', severity: "error" },
|
||||
{ pointer: "/manifest/itemCount", message: "says 42, payload has 41", severity: "warning" },
|
||||
]);
|
||||
const lines = rendered.split("\n");
|
||||
expect(lines[0]).toMatch(/^\/items\/17\/login\/uris\/0\/match {2}"fuzzy"/);
|
||||
expect(lines[1]).toContain("warning: says 42");
|
||||
});
|
||||
|
||||
it("renders nothing for no diagnostics", () => {
|
||||
expect(formatDiagnostics([])).toBe("");
|
||||
});
|
||||
});
|
||||
286
packages/opencreds/src/validate.ts
Normal file
286
packages/opencreds/src/validate.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* Structural validation with JSON pointers.
|
||||
*
|
||||
* Deliberately not Ajv. This package runs in a browser extension's service
|
||||
* worker, where a schema compiler is both weight and a CSP problem, and the CLI
|
||||
* contract asks for one diagnostic per failure pointing at the exact location —
|
||||
* which is easier to produce well by hand than to extract from a validator's
|
||||
* error objects. `@logicsrc/validators` holds the published JSON Schemas for
|
||||
* anyone who wants schema-based validation instead.
|
||||
*/
|
||||
|
||||
import { ITEM_TYPE, ITEM_TYPE_NAMES, MAX_HISTORY_ENTRIES, OPENCREDS_VERSION } from "./types.js";
|
||||
import { NAMESPACE_PATTERN, REGISTERED_NAMESPACES } from "./types.js";
|
||||
import type { Database, Item } from "./types.js";
|
||||
|
||||
export interface Diagnostic {
|
||||
/** JSON pointer into the document. */
|
||||
pointer: string;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
||||
const URI_MATCHES = ["domain", "host", "startsWith", "exact", "regex", "never"];
|
||||
const FIELD_KINDS = ["text", "hidden", "boolean", "linked"];
|
||||
const KEY_KINDS = ["ssh", "pgp", "api", "symmetric", "certificate", "env"];
|
||||
const GROUPS = ["login", "card", "identity", "key", "account"];
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Validate one item. `at` is the pointer prefix, e.g. "/items/17". */
|
||||
export function validateItem(value: unknown, at = ""): Diagnostic[] {
|
||||
const out: Diagnostic[] = [];
|
||||
const err = (pointer: string, message: string): void => {
|
||||
out.push({ pointer: `${at}${pointer}`, message, severity: "error" });
|
||||
};
|
||||
|
||||
if (!isObject(value)) {
|
||||
return [{ pointer: at || "/", message: "an item must be an object", severity: "error" }];
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value.v) || (value.v as number) < 1) {
|
||||
err("/v", "missing or invalid item schema version");
|
||||
}
|
||||
if (typeof value.id !== "string" || !UUID.test(value.id)) {
|
||||
err("/id", "id must be a UUID; it is bound into the ciphertext and cannot be reassigned");
|
||||
}
|
||||
if (typeof value.type !== "string" || !(value.type in ITEM_TYPE)) {
|
||||
err("/type", `${JSON.stringify(value.type)} is not one of ${ITEM_TYPE_NAMES.join(", ")}`);
|
||||
}
|
||||
if (typeof value.name !== "string") err("/name", "name must be a string (it may be empty)");
|
||||
for (const field of ["createdAt", "updatedAt"] as const) {
|
||||
if (typeof value[field] !== "string" || !TIMESTAMP.test(value[field] as string)) {
|
||||
err(`/${field}`, "must be an RFC 3339 timestamp");
|
||||
}
|
||||
}
|
||||
|
||||
const type = value.type as string;
|
||||
|
||||
// A group belonging to another type is either a broken importer or an attempt
|
||||
// to smuggle a field past a type-based permission check.
|
||||
for (const group of GROUPS) {
|
||||
if (group !== type && value[group] !== undefined) {
|
||||
err(`/${group}`, `a ${type} item must not carry a ${group} field group`);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "login" && value.login !== undefined) {
|
||||
const login = value.login;
|
||||
if (!isObject(login)) {
|
||||
err("/login", "must be an object");
|
||||
} else if (login.uris !== undefined) {
|
||||
if (!Array.isArray(login.uris)) {
|
||||
err("/login/uris", "must be an array");
|
||||
} else {
|
||||
login.uris.forEach((uri, i) => {
|
||||
if (!isObject(uri)) {
|
||||
err(`/login/uris/${i}`, "must be an object");
|
||||
return;
|
||||
}
|
||||
if (typeof uri.uri !== "string") err(`/login/uris/${i}/uri`, "must be a string");
|
||||
if (uri.match !== undefined && !URI_MATCHES.includes(uri.match as string)) {
|
||||
err(`/login/uris/${i}/match`, `${JSON.stringify(uri.match)} is not a valid match rule`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "key" && isObject(value.key)) {
|
||||
const key = value.key;
|
||||
if (key.keyType !== undefined && key.keyType !== "" && !KEY_KINDS.includes(key.keyType as string)) {
|
||||
err("/key/keyType", `${JSON.stringify(key.keyType)} is not one of ${KEY_KINDS.join(", ")}`);
|
||||
}
|
||||
if (typeof key.mode === "string" && key.mode !== "" && !/^0?[0-7]{3}$/.test(key.mode)) {
|
||||
err("/key/mode", "must be an octal POSIX mode such as \"0600\"");
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "account" && isObject(value.account) && value.account.scopes !== undefined) {
|
||||
if (!Array.isArray(value.account.scopes)) err("/account/scopes", "must be an array of strings");
|
||||
}
|
||||
|
||||
if (value.history !== undefined) {
|
||||
if (!Array.isArray(value.history)) {
|
||||
err("/history", "must be an array");
|
||||
} else {
|
||||
if (type !== "login" && value.history.length > 0) {
|
||||
err("/history", "password history is defined only for login items");
|
||||
}
|
||||
if (value.history.length > MAX_HISTORY_ENTRIES) {
|
||||
err("/history", `history is capped at ${MAX_HISTORY_ENTRIES} entries, found ${value.history.length}`);
|
||||
}
|
||||
value.history.forEach((entry, i) => {
|
||||
if (!isObject(entry) || typeof entry.password !== "string") {
|
||||
err(`/history/${i}/password`, "must be a string");
|
||||
}
|
||||
if (!isObject(entry) || typeof entry.changedAt !== "string" || !TIMESTAMP.test(entry.changedAt)) {
|
||||
err(`/history/${i}/changedAt`, "must be an RFC 3339 timestamp");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.fields !== undefined) {
|
||||
if (!Array.isArray(value.fields)) {
|
||||
err("/fields", "must be an array");
|
||||
} else {
|
||||
value.fields.forEach((field, i) => {
|
||||
if (!isObject(field)) {
|
||||
err(`/fields/${i}`, "must be an object");
|
||||
return;
|
||||
}
|
||||
if (typeof field.name !== "string") err(`/fields/${i}/name`, "must be a string");
|
||||
if (typeof field.value !== "string") err(`/fields/${i}/value`, "must be a string");
|
||||
if (!FIELD_KINDS.includes(field.type as string)) {
|
||||
err(`/fields/${i}/type`, `${JSON.stringify(field.type)} is not one of ${FIELD_KINDS.join(", ")}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (value.folderId !== undefined && value.folderId !== null && typeof value.folderId !== "string") {
|
||||
err("/folderId", "must be a folder id or null");
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Validate a database document. Does not decrypt; see openDatabase for that. */
|
||||
export function validateDatabase(value: unknown): Diagnostic[] {
|
||||
const out: Diagnostic[] = [];
|
||||
const err = (pointer: string, message: string): void => {
|
||||
out.push({ pointer, message, severity: "error" });
|
||||
};
|
||||
const warn = (pointer: string, message: string): void => {
|
||||
out.push({ pointer, message, severity: "warning" });
|
||||
};
|
||||
|
||||
if (!isObject(value)) {
|
||||
return [{ pointer: "/", message: "a database must be a JSON object", severity: "error" }];
|
||||
}
|
||||
if (value.type !== "opencreds.database") {
|
||||
err("/type", 'must be "opencreds.database"');
|
||||
}
|
||||
if (value.opencreds !== OPENCREDS_VERSION) {
|
||||
err("/opencreds", `unsupported version ${JSON.stringify(value.opencreds)}; this build reads ${OPENCREDS_VERSION}`);
|
||||
}
|
||||
if (typeof value.namespace !== "string" || !NAMESPACE_PATTERN.test(value.namespace)) {
|
||||
err("/namespace", "must match ^[a-z][a-z0-9-]{1,31}$");
|
||||
} else if (!REGISTERED_NAMESPACES.includes(value.namespace)) {
|
||||
warn("/namespace", `"${value.namespace}" is not a registered namespace; opening it needs an explicit opt-in`);
|
||||
}
|
||||
if (typeof value.exportedAt !== "string" || !TIMESTAMP.test(value.exportedAt)) {
|
||||
err("/exportedAt", "must be an RFC 3339 timestamp");
|
||||
}
|
||||
if (typeof value.protected !== "boolean") {
|
||||
err("/protected", "must be a boolean");
|
||||
}
|
||||
|
||||
const manifest = value.manifest;
|
||||
if (!isObject(manifest)) {
|
||||
err("/manifest", "missing; an OpenCreds database states what it contains and that statement is checked");
|
||||
} else {
|
||||
if (!Number.isInteger(manifest.itemCount)) err("/manifest/itemCount", "must be an integer");
|
||||
if (!Number.isInteger(manifest.folderCount)) err("/manifest/folderCount", "must be an integer");
|
||||
if (typeof manifest.digest !== "string") err("/manifest/digest", "must be a base64 SHA-256");
|
||||
if (manifest.types !== undefined && !isObject(manifest.types)) {
|
||||
err("/manifest/types", "must be an object of type name to count");
|
||||
}
|
||||
}
|
||||
|
||||
if (value.protected === true) {
|
||||
if (typeof value.iv !== "string") err("/iv", "an encrypted database must carry an iv");
|
||||
if (typeof value.ciphertext !== "string") err("/ciphertext", "an encrypted database must carry a ciphertext");
|
||||
if (value.items !== undefined) err("/items", "an encrypted database must not also state its items in the clear");
|
||||
if (value.kdf !== undefined) {
|
||||
if (!isObject(value.kdf)) {
|
||||
err("/kdf", "must be an object");
|
||||
} else {
|
||||
if (!Number.isInteger(value.kdf.iterations) || (value.kdf.iterations as number) < 100_000) {
|
||||
err("/kdf/iterations", "must be at least 100000");
|
||||
}
|
||||
if (typeof value.kdf.salt !== "string") err("/kdf/salt", "must be a base64 salt");
|
||||
}
|
||||
}
|
||||
} else if (value.protected === false) {
|
||||
if (!Array.isArray(value.items)) {
|
||||
err("/items", "a plaintext database must carry its items");
|
||||
} else {
|
||||
(value.items as unknown[]).forEach((item, i) => {
|
||||
out.push(...validateItem(item, `/items/${i}`));
|
||||
});
|
||||
}
|
||||
if (value.iv !== undefined || value.ciphertext !== undefined) {
|
||||
err("/ciphertext", "a plaintext database must not carry ciphertext");
|
||||
}
|
||||
warn("/protected", "this file holds every secret in the vault in the clear");
|
||||
}
|
||||
|
||||
if (value.folders !== undefined) {
|
||||
if (!Array.isArray(value.folders)) {
|
||||
err("/folders", "must be an array");
|
||||
} else {
|
||||
(value.folders as unknown[]).forEach((folder, i) => {
|
||||
if (!isObject(folder)) {
|
||||
err(`/folders/${i}`, "must be an object");
|
||||
return;
|
||||
}
|
||||
if (typeof folder.id !== "string" || !UUID.test(folder.id)) err(`/folders/${i}/id`, "must be a UUID");
|
||||
if (typeof folder.name !== "string") err(`/folders/${i}/name`, "must be a string");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate any OpenCreds document, guessing which kind it is.
|
||||
*
|
||||
* A person running `opencreds validate` on a file has a file, not a schema
|
||||
* name; asking them which kind it is would be asking them the question they
|
||||
* came here to answer.
|
||||
*/
|
||||
export function validateDocument(value: unknown): { kind: string; diagnostics: Diagnostic[] } {
|
||||
if (isObject(value) && value.type === "opencreds.database") {
|
||||
return { kind: "database", diagnostics: validateDatabase(value) };
|
||||
}
|
||||
if (isObject(value) && Array.isArray(value.items)) {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
(value.items as unknown[]).forEach((item, i) => diagnostics.push(...validateItem(item, `/items/${i}`)));
|
||||
return { kind: "items", diagnostics };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
value.forEach((item, i) => diagnostics.push(...validateItem(item, `/${i}`)));
|
||||
return { kind: "items", diagnostics };
|
||||
}
|
||||
return { kind: "item", diagnostics: validateItem(value) };
|
||||
}
|
||||
|
||||
export function hasErrors(diagnostics: Diagnostic[]): boolean {
|
||||
return diagnostics.some((d) => d.severity === "error");
|
||||
}
|
||||
|
||||
/** Render diagnostics the way the CLI contract specifies: pointer, then message. */
|
||||
export function formatDiagnostics(diagnostics: Diagnostic[]): string {
|
||||
if (diagnostics.length === 0) return "";
|
||||
const width = Math.max(...diagnostics.map((d) => d.pointer.length));
|
||||
return diagnostics
|
||||
.map((d) => `${d.pointer.padEnd(width)} ${d.severity === "warning" ? "warning: " : ""}${d.message}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** A rough type guard for a parsed database, before the deeper checks run. */
|
||||
export function looksLikeDatabase(value: unknown): value is Database {
|
||||
return isObject(value) && value.type === "opencreds.database";
|
||||
}
|
||||
|
||||
export function looksLikeItem(value: unknown): value is Item {
|
||||
return isObject(value) && typeof value.type === "string" && value.type in ITEM_TYPE;
|
||||
}
|
||||
279
packages/opencreds/src/vault-key.ts
Normal file
279
packages/opencreds/src/vault-key.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* Vault creation, unlock, recovery and re-keying.
|
||||
*
|
||||
* The user key is 32 random bytes, generated once and wrapped. It is not
|
||||
* derived from the password, so changing the master password re-wraps 32 bytes
|
||||
* instead of re-encrypting every item — and a partial failure during a password
|
||||
* change cannot leave half a vault openable by the old password and half by the
|
||||
* new.
|
||||
*/
|
||||
|
||||
import {
|
||||
aesGcmDecrypt,
|
||||
aesGcmEncrypt,
|
||||
fromBase64,
|
||||
KEY_BYTES,
|
||||
randomBytes,
|
||||
toBase64,
|
||||
} from "./primitives.js";
|
||||
import {
|
||||
DEFAULT_KDF_PARAMS,
|
||||
assertUsableKdfParams,
|
||||
assertUsableNamespace,
|
||||
deriveAll,
|
||||
deriveAuthHash,
|
||||
deriveMasterKey,
|
||||
deriveRecoveryWrapKey,
|
||||
deriveWrapKey,
|
||||
} from "./kdf.js";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
OPENCREDS_VERSION,
|
||||
type KdfParams,
|
||||
type Namespace,
|
||||
type Profile,
|
||||
type VaultMeta,
|
||||
} from "./types.js";
|
||||
|
||||
export const SALT_BYTES = 16;
|
||||
export const RECOVERY_KEY_BYTES = 16;
|
||||
|
||||
/**
|
||||
* Crockford base32: the digits, then the letters without I, L, O or U.
|
||||
*
|
||||
* I/1, L/1 and O/0 are the pairs people actually confuse, and U is dropped so
|
||||
* that no accidental word offends anyone. Crockford also defines how to decode
|
||||
* the confusions, which {@link parseRecoveryKey} implements.
|
||||
*/
|
||||
const RECOVERY_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
/**
|
||||
* Render a recovery key for a human to write down.
|
||||
*
|
||||
* Groups of five, because this value is transcribed by hand exactly once and
|
||||
* misread forever after.
|
||||
*/
|
||||
export function formatRecoveryKey(bytes: Uint8Array): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const byte of bytes) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += RECOVERY_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += RECOVERY_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return (out.match(/.{1,5}/g) ?? []).join("-");
|
||||
}
|
||||
|
||||
/** Parse a recovery key back, tolerating case, spaces and dashes. */
|
||||
export function parseRecoveryKey(input: string): Uint8Array {
|
||||
const clean = String(input || "")
|
||||
.toUpperCase()
|
||||
// Dashes, spaces and anything else a person adds while writing it down.
|
||||
.replace(/[^0-9A-Z]/g, "")
|
||||
// Crockford's decoding rule for the characters the alphabet omits.
|
||||
.replace(/O/g, "0")
|
||||
.replace(/[IL]/g, "1");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const char of clean) {
|
||||
const index = RECOVERY_ALPHABET.indexOf(char);
|
||||
if (index < 0) throw new Error(`Invalid character in recovery key: ${char}`);
|
||||
value = (value << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export interface CreatedVault {
|
||||
meta: VaultMeta;
|
||||
userKey: Uint8Array;
|
||||
recoveryKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `user`-profile vault.
|
||||
*
|
||||
* Returns the metadata (safe to send to a server), the user key (never), and
|
||||
* the recovery key, which is shown to the person exactly once and then is gone
|
||||
* from this process.
|
||||
*/
|
||||
export async function createVault(
|
||||
password: string,
|
||||
options: { namespace?: Namespace; params?: KdfParams; allowUnregisteredNamespace?: boolean } = {},
|
||||
): Promise<CreatedVault> {
|
||||
const namespace = assertUsableNamespace(
|
||||
options.namespace ?? DEFAULT_NAMESPACE,
|
||||
options.allowUnregisteredNamespace,
|
||||
);
|
||||
const params = assertUsableKdfParams(options.params ?? DEFAULT_KDF_PARAMS);
|
||||
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const userKey = randomBytes(KEY_BYTES);
|
||||
const { wrapKey, authHash } = await deriveAll(password, salt, params, namespace);
|
||||
|
||||
const wrapped = await aesGcmEncrypt(wrapKey, userKey);
|
||||
|
||||
const recoveryBytes = randomBytes(RECOVERY_KEY_BYTES);
|
||||
const recoveryWrapKey = await deriveRecoveryWrapKey(recoveryBytes, namespace);
|
||||
const recoveryWrapped = await aesGcmEncrypt(recoveryWrapKey, userKey);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const meta: VaultMeta = {
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
namespace,
|
||||
profile: "user",
|
||||
kdf: params.kdf,
|
||||
kdfIterations: params.iterations,
|
||||
kdfSalt: toBase64(salt),
|
||||
protectedUserKey: toBase64(wrapped.ciphertext),
|
||||
protectedUserKeyIv: toBase64(wrapped.iv),
|
||||
recoveryKeyBlob: toBase64(recoveryWrapped.ciphertext),
|
||||
recoveryKeyIv: toBase64(recoveryWrapped.iv),
|
||||
authHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
return { meta, userKey, recoveryKey: formatRecoveryKey(recoveryBytes) };
|
||||
}
|
||||
|
||||
/** Create a `team`-profile vault: a random key, wrapped by the caller's scheme. */
|
||||
export function createTeamVault(namespace: Namespace = DEFAULT_NAMESPACE): { meta: VaultMeta; userKey: Uint8Array } {
|
||||
const ns = assertUsableNamespace(namespace);
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
userKey: randomBytes(KEY_BYTES),
|
||||
meta: {
|
||||
opencreds: OPENCREDS_VERSION,
|
||||
namespace: ns,
|
||||
profile: "team",
|
||||
kdf: "pbkdf2-sha256",
|
||||
kdfIterations: DEFAULT_KDF_PARAMS.iterations,
|
||||
kdfSalt: toBase64(randomBytes(SALT_BYTES)),
|
||||
// A team vault's key is sealed to member public keys by the caller
|
||||
// (see plugins/credential-sharing), so there is no password-wrapped copy.
|
||||
protectedUserKey: "",
|
||||
protectedUserKeyIv: "",
|
||||
wrappedKeys: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function paramsOf(meta: VaultMeta): KdfParams {
|
||||
return assertUsableKdfParams({ kdf: meta.kdf, iterations: meta.kdfIterations });
|
||||
}
|
||||
|
||||
export function assertProfile(meta: VaultMeta, supported: Profile[]): void {
|
||||
if (!supported.includes(meta.profile)) {
|
||||
throw new Error(
|
||||
`This implementation does not support the "${meta.profile}" profile; refusing to open the vault`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlock with the master password. Returns the user key. */
|
||||
export async function unlockVault(
|
||||
meta: VaultMeta,
|
||||
password: string,
|
||||
options: { allowUnregisteredNamespace?: boolean } = {},
|
||||
): Promise<Uint8Array> {
|
||||
assertProfile(meta, ["user"]);
|
||||
const namespace = assertUsableNamespace(meta.namespace, options.allowUnregisteredNamespace);
|
||||
const params = paramsOf(meta);
|
||||
|
||||
const masterKey = await deriveMasterKey(password, fromBase64(meta.kdfSalt), params);
|
||||
const wrapKey = await deriveWrapKey(masterKey, namespace);
|
||||
try {
|
||||
return await aesGcmDecrypt(
|
||||
wrapKey,
|
||||
fromBase64(meta.protectedUserKeyIv),
|
||||
fromBase64(meta.protectedUserKey),
|
||||
);
|
||||
} catch {
|
||||
throw new Error("Wrong master password");
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlock with the recovery key, for the day the password is gone. */
|
||||
export async function unlockWithRecoveryKey(
|
||||
meta: VaultMeta,
|
||||
recoveryKey: string,
|
||||
options: { allowUnregisteredNamespace?: boolean } = {},
|
||||
): Promise<Uint8Array> {
|
||||
assertProfile(meta, ["user"]);
|
||||
const namespace = assertUsableNamespace(meta.namespace, options.allowUnregisteredNamespace);
|
||||
if (!meta.recoveryKeyBlob || !meta.recoveryKeyIv) {
|
||||
throw new Error("This vault has no recovery key");
|
||||
}
|
||||
const wrapKey = await deriveRecoveryWrapKey(parseRecoveryKey(recoveryKey), namespace);
|
||||
try {
|
||||
return await aesGcmDecrypt(wrapKey, fromBase64(meta.recoveryKeyIv), fromBase64(meta.recoveryKeyBlob));
|
||||
} catch {
|
||||
throw new Error("Wrong recovery key");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the master password.
|
||||
*
|
||||
* Re-wraps the same user key, so not one item is touched. The recovery blob is
|
||||
* left alone: it wraps the same key under a value the person still holds.
|
||||
*/
|
||||
export async function rewrapUserKey(
|
||||
meta: VaultMeta,
|
||||
userKey: Uint8Array,
|
||||
newPassword: string,
|
||||
params: KdfParams = DEFAULT_KDF_PARAMS,
|
||||
): Promise<VaultMeta> {
|
||||
assertProfile(meta, ["user"]);
|
||||
const namespace = assertUsableNamespace(meta.namespace, true);
|
||||
const usable = assertUsableKdfParams(params);
|
||||
const salt = randomBytes(SALT_BYTES);
|
||||
const masterKey = await deriveMasterKey(newPassword, salt, usable);
|
||||
const wrapKey = await deriveWrapKey(masterKey, namespace);
|
||||
const wrapped = await aesGcmEncrypt(wrapKey, userKey);
|
||||
|
||||
return {
|
||||
...meta,
|
||||
kdf: usable.kdf,
|
||||
kdfIterations: usable.iterations,
|
||||
kdfSalt: toBase64(salt),
|
||||
protectedUserKey: toBase64(wrapped.ciphertext),
|
||||
protectedUserKeyIv: toBase64(wrapped.iv),
|
||||
authHash: await deriveAuthHash(masterKey, namespace),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Issue a fresh recovery key, invalidating the old one. */
|
||||
export async function resetRecoveryKey(
|
||||
meta: VaultMeta,
|
||||
userKey: Uint8Array,
|
||||
): Promise<{ meta: VaultMeta; recoveryKey: string }> {
|
||||
assertProfile(meta, ["user"]);
|
||||
const namespace = assertUsableNamespace(meta.namespace, true);
|
||||
const recoveryBytes = randomBytes(RECOVERY_KEY_BYTES);
|
||||
const wrapKey = await deriveRecoveryWrapKey(recoveryBytes, namespace);
|
||||
const wrapped = await aesGcmEncrypt(wrapKey, userKey);
|
||||
return {
|
||||
meta: {
|
||||
...meta,
|
||||
recoveryKeyBlob: toBase64(wrapped.ciphertext),
|
||||
recoveryKeyIv: toBase64(wrapped.iv),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
recoveryKey: formatRecoveryKey(recoveryBytes),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue