diff --git a/apps/logicsrc-web/src/app/opencreds/page.tsx b/apps/logicsrc-web/src/app/opencreds/page.tsx new file mode 100644 index 0000000..fe1b57d --- /dev/null +++ b/apps/logicsrc-web/src/app/opencreds/page.tsx @@ -0,0 +1,372 @@ +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { SiteShell } from "@/components/site-shell"; +import { card, mono, pre, table, td, th } from "../openontology/ui"; + +export const metadata: Metadata = { + title: "OpenCreds · LogicSRC", + description: + "OpenCreds is an open standard for credential records and portable vaults: one record for logins, cards, identities, notes, keys and accounts, an end-to-end-encrypted envelope, and a single encrypted file that moves a vault between products without a plaintext CSV.", + alternates: { canonical: "/opencreds" } +}; + +const TYPES: Array<[type: string, code: string, holds: string]> = [ + ["login", "1", "Username, password, TOTP seed, matching URIs, password history"], + ["card", "2", "Cardholder, brand, number, expiry, security code"], + ["identity", "3", "Name, address, and identity document numbers"], + ["note", "4", "Free text, plus any custom fields"], + ["key", "5", "SSH and PGP keys, API tokens, certificates, .env secrets"], + ["account", "6", "A provider account and the OAuth tokens that act as it"] +]; + +const SCHEMAS: Array<[name: string, file: string]> = [ + ["Item", "logicsrc-opencreds-item.schema.json"], + ["Item envelope", "logicsrc-opencreds-envelope.schema.json"], + ["Vault metadata", "logicsrc-opencreds-vault-meta.schema.json"], + ["Database", "logicsrc-opencreds-database.schema.json"], + ["Manifest", "logicsrc-opencreds-manifest.schema.json"], + ["Audit event", "logicsrc-opencreds-audit-event.schema.json"] +]; + +const DOCS: Array<[slug: string, title: string, blurb: string]> = [ + ["opencreds", "Overview", "What the standard defines, and what it deliberately does not."], + ["credential-sharing", "Credential Sharing", "The sync half: moving a key/value pair between providers."] +]; + +export default function OpenCredsPage(): ReactNode { + return ( + +
+
+

LogicSRC standards surface

+

OpenCreds

+

+ An open standard for credential records and portable vaults. It defines + what a credential item is, how a vault is encrypted, and what a vault looks like as a + file — so that moving a vault between two products is a supported operation rather than + a plaintext CSV export. +

+
+

+ 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. 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. +

+

+ Status: 0.1 draft. Reference implementation:{" "} + @logicsrc/opencreds. A conforming vault is a file and a key — no + account, no server, no network call. +

+
+ +
+
+

One record, six types

+

+ Logins, cards, identities, notes, keys and accounts are not six features. They are one + record with a type and a named field group, so 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. +

+
+
+ + + + + + + + + + {TYPES.map(([type, code, holds]) => ( + + + + + + ))} + +
TypeCodeWhat it holds
{type}{code}{holds}
+
+

+ The type code is stored in plaintext beside the ciphertext so a server can filter and + paginate without decrypting. That is the metadata the design accepts leaking, and it says + so rather than obscuring it: a server learns you hold forty logins and two cards, never + which sites or what values. +

+
{`{
+  "v": 1,
+  "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21",
+  "type": "login",
+  "name": "GitHub",
+  "folderId": null,
+  "login": {
+    "username": "anthony",
+    "password": "…",
+    "totp": "otpauth://totp/GitHub:anthony?secret=…",
+    "uris": [{ "uri": "https://github.com", "match": "domain" }]
+  },
+  "history": [],
+  "createdAt": "2026-08-29T00:00:00.000Z",
+  "updatedAt": "2026-08-29T00:00:00.000Z"
+}`}
+
+ +
+
+

One envelope, one key hierarchy

+

AES-256-GCM over the record, with the item id bound in as additional authenticated data.

+
+
{`master password
+      │  PBKDF2-HMAC-SHA256(salt, 600,000)      ← the only expensive step
+      ▼
+ master key (32 bytes)         never encrypts anything itself
+      │
+      ├─ HKDF(":vault:wrap:v1")     → wrap key  → AES-GCM → protected user key
+      ├─ HKDF(":vault:auth:v1")     → auth hash → server (hashed again there)
+      └─ HKDF(":vault:recovery:v1") → recovery wrap → recovery blob
+
+ user key (32 random bytes)    ← what every item is actually encrypted under
+      │
+      └─ AES-256-GCM(iv, item JSON, AAD = ":vault:item::")`}
+
+
+ Why the id is in the AAD. +

+ Without it a ciphertext is portable between rows. Anyone with write access to the + storage could copy a low-value login’s ciphertext into a high-value one’s + row and watch what the user does next — they unlock, see the credential they expected + under a name they trust, and use it. With the id bound in, that swap fails to decrypt. +

+
+
+ Why the user key is random, not derived. +

+ A master password change re-wraps 32 bytes. Derive item keys from the password instead + and every change rewrites every item — a long window in which a partial failure leaves + half the vault openable by the old password and half by the new. +

+
+
+ Why the auth hash cannot decrypt. +

+ The wrap key and the auth hash come out of the same master key under different HKDF + labels, whose outputs are computationally independent. A server holding every auth hash + it has ever seen holds nothing that helps it derive a wrapping key. That is what makes + “the server cannot read the vault” a property rather than a promise. +

+
+
+ Why the KDF floor is checked in the client. +

+ Parameters arrive from a server, so they are attacker-controlled the moment it is + compromised. A client that trusted iterations: 1 would hand + an attacker who has been capturing auth hashes an offline guessing exercise with no + work factor. Conforming clients refuse below 100,000 before deriving anything. +

+
+
+
+ +
+
+

One file

+

+ A vault exports as a single .opencreds JSON document, + encrypted by default, whose header is bound as additional authenticated data over the + payload. +

+
+
{`{
+  "opencreds": "0.1",
+  "type": "opencreds.database",
+  "protected": true,
+  "namespace": "opencreds",
+  "exportedAt": "2026-08-29T18:00:00.000Z",
+  "generator": { "name": "@logicsrc/opencreds", "version": "0.1.0" },
+  "kdf": { "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "…" },
+  "manifest": {
+    "itemCount": 42,
+    "types": { "login": 38, "card": 2, "key": 1, "account": 1 },
+    "folderCount": 3,
+    "digest": "…"
+  },
+  "iv": "…",
+  "ciphertext": "…"
+}`}
+

+ Because the header is the AAD, the manifest is authenticated by the same tag as the data. + The counts can be shown in a preview before anyone types a passphrase, and they cannot be + lied about. After decrypting, a conforming implementation recomputes all four fields and + refuses the import if any disagrees. +

+

+ 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. A database that was truncated does not decrypt + at all; one edited after decryption fails its digest. There is no state in which a + conforming implementation reports a complete import of an incomplete file. +

+
+ The plaintext form exists, and it is loud. +

+ Some people are moving to a product that reads nothing else, and an export + format that cannot express that gets worked around with a script that is worse — no + warning, no file mode, no label. So it is specified: never the default, an explicit flag + plus a confirmation, owner-only file mode, and{" "} + "protected": false in the header so tooling can + identify the file without parsing the rest of it. +

+
+
+ +
+
+

Namespaces, and why they are data

+
+

+ Every domain-separation label is prefixed by the vault’s{" "} + namespace. This is not decoration. A label is compiled into the + additional authenticated data of every ciphertext a vault has ever written, and into the + HKDF derivation of its keys. Change a label string and every vault in the world that used + it becomes undecryptable — not corrupted, not recoverable, undecryptable. +

+

+ So labels are append-only in the strongest sense available: superseded by a new{" "} + :v2 label, never edited. And because MarkSyncr’s vault + shipped with marksyncr:vault:* labels before this specification + existed, the prefix is carried as a per-vault property. A deployed vault declares its + namespace and is conformant; a new one uses opencreds. +

+
+ +
+
+

Two profiles, one envelope

+

A profile is how the user key is managed. The item envelope is identical in both.

+
+
+
+ user +

+ The user key is wrapped by a key derived from a master password. One person, one + password, one vault. +

+
+
+ team +

+ The vault key is random and sealed to each member’s X25519 public key. The server + holds one wrapped key per member and never the key itself; granting access is an + existing member unwrapping and re-sealing. This is the scheme{" "} + logicsrc credentials already implements — OpenCreds adds only + the observation that the thing being wrapped can be a vault of items rather than a bag + of strings. +

+

+ Stated plainly: every member holding the vault key reads every item in it. Revoking a + member means rotating the key and re-encrypting, because a key they held is a key they + may have kept. Partial sharing is not a feature of a shared key; it is a second vault. +

+
+
+
+ +
+
+

Using it

+

+ The same commands ship as logicsrc vault … and as the + standalone opencreds binary, from one implementation, so the + two cannot drift. +

+
+
{`# Create a vault; prints a recovery key exactly once
+logicsrc vault init
+
+# Add items
+logicsrc vault add login --name GitHub --username anthony --url https://github.com
+logicsrc vault add card  --name "Visa ending 4242"
+logicsrc vault add key   --name "deploy key" --key-type ssh --file ~/.ssh/id_ed25519
+
+# List and read; never prints a secret unless you name one
+logicsrc vault list --type login
+logicsrc vault get GitHub --field login.password --reveal
+
+# Move the vault, encrypted, and preview before writing
+logicsrc vault export --out vault.opencreds
+logicsrc vault import vault.opencreds --dry-run
+
+# Arrive from somewhere else
+logicsrc vault import bitwarden-export.csv --source bitwarden --dry-run`}
+

+ Importers ship for Bitwarden, 1Password, Chrome, LastPass and KeePass. A row that cannot be + mapped is reported with its line number and a reason rather than dropped — the person still + has the source file, and only knows to go back for it if they are told. +

+
+ +
+
+

Schemas

+

+ Published in @logicsrc/schemas as JSON Schema draft 2020-12, + so a third party can conform without reading LogicSRC source. +

+
+
+ + + + + + + + + {SCHEMAS.map(([name, file]) => ( + + + + + ))} + +
SchemaFile
{name}{file}
+
+
+ {DOCS.map(([slug, title, blurb]) => ( + + {title} +

{blurb}

+
+ ))} +
+
+ +
+
+

How it relates to the other specs

+
+
+
+ Credential Sharing moves secrets between providers — .env, + Doppler, Railway, GitHub, SSH — and models a key/value pair and a sync plan. OpenCreds + models the record and the vault file. They meet at the{" "} + key item: a synced .env entry, stored rather than moved. +
+
+ OpenContext governs what an agent may read. An agent resolving + a context bundle may be entitled to one OpenCreds item and not the vault; the permission + decision is OpenContext’s, the record shape is OpenCreds’. +
+
+ OpenOntology names the entities a credential belongs to. An{" "} + account item’s provider is an + ontology entity, not a free string, where an ontology is in use. +
+
+
+
+ ); +} diff --git a/apps/logicsrc-web/src/app/sitemap.ts b/apps/logicsrc-web/src/app/sitemap.ts index ec26fa3..46b9682 100644 --- a/apps/logicsrc-web/src/app/sitemap.ts +++ b/apps/logicsrc-web/src/app/sitemap.ts @@ -17,6 +17,7 @@ const STATIC_ROUTES: Array<{ { path: "/", changeFrequency: "weekly", priority: 1.0 }, { path: "/docs", changeFrequency: "weekly", priority: 0.9 }, { path: "/openontology", changeFrequency: "weekly", priority: 0.9 }, + { path: "/opencreds", changeFrequency: "weekly", priority: 0.9 }, { path: "/openprd", changeFrequency: "weekly", priority: 0.9 }, { path: "/openontology/explore", changeFrequency: "daily", priority: 0.7 }, { path: "/openspec", changeFrequency: "weekly", priority: 0.8 }, diff --git a/apps/logicsrc-web/src/components/site-shell.tsx b/apps/logicsrc-web/src/components/site-shell.tsx index 13b5347..9375896 100644 --- a/apps/logicsrc-web/src/components/site-shell.tsx +++ b/apps/logicsrc-web/src/components/site-shell.tsx @@ -10,6 +10,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [ { href: "/agentbyte", label: "AgentByte" }, { href: "/credential-sharing", label: "Credentials" }, { href: "/openontology", label: "OpenOntology" }, + { href: "/opencreds", label: "OpenCreds" }, { href: "/openprd", label: "OpenPRD" }, { href: "/#cli", label: "CLI" }, { href: "/docs", label: "Docs" }, diff --git a/apps/logicsrc-web/src/lib/docs.ts b/apps/logicsrc-web/src/lib/docs.ts index 924958e..1c02bb7 100644 --- a/apps/logicsrc-web/src/lib/docs.ts +++ b/apps/logicsrc-web/src/lib/docs.ts @@ -8,6 +8,7 @@ const DOCS_DIR = resolve(process.cwd(), "../../docs"); // Curated, public-facing reference docs. Internal notes (roadmap, positioning, // arcade) are intentionally excluded. export const DOC_SLUGS = [ + "opencreds", "openprd", "openontology", "openontology-governance", diff --git a/docs/opencreds.md b/docs/opencreds.md new file mode 100644 index 0000000..2a3caa0 --- /dev/null +++ b/docs/opencreds.md @@ -0,0 +1,129 @@ +# OpenCreds + +Status: 0.1 draft · reference implementation available (`@logicsrc/opencreds`) + +Slug: `opencreds` + +OpenCreds is a LogicSRC OpenSpec for **credential records and portable vaults**. +It defines what a credential item is, how a vault is encrypted, and what a vault +looks like as a file — so that moving a vault between two products is a +supported operation rather than a plaintext CSV export. + +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. + +- Full specification: [`docs/opencreds/spec.md`](./opencreds/spec.md) +- Item model: [`docs/opencreds/item-model.md`](./opencreds/item-model.md) +- Cryptography: [`docs/opencreds/crypto.md`](./opencreds/crypto.md) +- Portable database: [`docs/opencreds/database.md`](./opencreds/database.md) +- Importing from other products: [`docs/opencreds/interop.md`](./opencreds/interop.md) +- CLI: [`docs/opencreds/cli.md`](./opencreds/cli.md) +- Conformance: [`docs/opencreds/conformance.md`](./opencreds/conformance.md) +- Security model: [`docs/opencreds/security.md`](./opencreds/security.md) +- FAQ: [`docs/opencreds/faq.md`](./opencreds/faq.md) + +## What it defines + +**One record, six types.** 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. + +```json +{ + "v": 1, + "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21", + "type": "login", + "name": "GitHub", + "folderId": null, + "notes": "", + "login": { + "username": "anthony", + "password": "…", + "totp": "otpauth://totp/GitHub:anthony?secret=…", + "uris": [{ "uri": "https://github.com", "match": "domain" }] + }, + "history": [], + "createdAt": "2026-08-29T00:00:00.000Z", + "updatedAt": "2026-08-29T00:00:00.000Z" +} +``` + +**One envelope.** AES-256-GCM over that JSON, with the item id bound in as +additional authenticated data. A ciphertext moved from one row to another fails +to decrypt rather than quietly showing the wrong credential — without that, +anyone with database write access could swap a low-value login's ciphertext into +a high-value one and watch what the user does next. + +**One key hierarchy.** The master password is stretched once by PBKDF2-SHA256 +into a master key, and everything else is derived from it by HKDF with a +distinct label. The only password-derived value that ever reaches a server comes +out of a different label than the wrapping key, so holding it does not help +decrypt anything. + +**One file.** A vault exports as a single `.opencreds` JSON document, encrypted +by default, carrying a manifest — item count, type histogram, digest over the +item ids — that is authenticated by the same tag as the data. A truncated +import fails instead of looking like a complete one. + +## What it does not define + +Sync. Storage. Autofill. A conforming vault is a file and a key; how two devices +reconcile, where the ciphertext lives, and how a browser fills a form are all +left to the implementation. + +## Implementations + +| Implementation | Profile | Namespace | Notes | +| --- | --- | --- | --- | +| `@logicsrc/opencreds` | `user`, `team` | `opencreds` | Reference implementation; local store and CLI | +| `logicsrc credentials` | `team` | `opencreds` | `.env` secrets and SSH keys as `key` items | +| `@marksyncr/vault` | `user` | `marksyncr` | Pre-dates the spec; conformant via its declared namespace | + +MarkSyncr's vault shipped before OpenCreds and has domain-separation labels +baked into every ciphertext already written. Labels cannot be edited — changing +one makes every existing vault undecryptable — so the spec carries the label +prefix as a declared per-vault `namespace` rather than mandating a single +string. See [crypto.md](./opencreds/crypto.md#namespaces). + +## Quick start + +```bash +# Create a vault (asks for a master password; prints a recovery key once) +logicsrc vault init + +# Add items +logicsrc vault add login --name GitHub --username anthony --url https://github.com +logicsrc vault add card --name "Visa ending 4242" +logicsrc vault add key --name "deploy key" --key-type ssh --file ~/.ssh/id_ed25519 + +# List (never prints secret values) +logicsrc vault list --type login + +# Move the vault somewhere else, encrypted +logicsrc vault export --out vault.opencreds +logicsrc vault import vault.opencreds --dry-run + +# Import from another product +logicsrc vault import bitwarden-export.csv --source bitwarden --dry-run +``` + +The same commands ship as the standalone `opencreds` binary, so +`logicsrc vault validate` and `opencreds validate` are the same contract. + +## Relationship to the other LogicSRC specs + +- **Credential Sharing** ([credential-sharing.md](./credential-sharing.md)) + moves secrets *between providers* — `.env`, Doppler, Railway, GitHub, SSH. It + models a key/value pair and a sync plan. OpenCreds models the **record** and + the **vault file**. A `key` item is what a synced `.env` entry becomes when it + is stored rather than moved. +- **OpenContext** ([opencontext.md](./opencontext.md)) governs what an agent may + *read*. An agent that resolves a context bundle may be entitled to one + OpenCreds item and not the vault; the permission decision is OpenContext's, + the record shape is OpenCreds'. +- **OpenOntology** ([openontology.md](./openontology.md)) names the entities a + credential belongs to. An `account` item's `provider` is an ontology entity, + not a free string, where an ontology is in use. diff --git a/docs/opencreds/cli.md b/docs/opencreds/cli.md new file mode 100644 index 0000000..851d878 --- /dev/null +++ b/docs/opencreds/cli.md @@ -0,0 +1,155 @@ +# The OpenCreds CLI + +The CLI is part of the conformance surface: flags, output shapes and exit codes +are specified, not incidental. The same commands ship twice — as +`logicsrc vault …` and as the standalone `opencreds` binary — from one +implementation, so the two can never drift. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Success. | +| 1 | Usage error — unknown flag, missing argument, unreadable file. | +| 2 | Validation failure — a document did not conform. | +| 3 | Crypto failure — wrong password, failed tag, manifest mismatch. | +| 4 | Refused — the operation needs a confirmation that was not given. | + +## Vault + +```bash +opencreds init [--namespace opencreds] [--iterations 600000] [--password-stdin] +``` + +Creates a vault. Prompts for a master password twice, prints a recovery key +once, and never prints it again. Refuses if a vault already exists at the target +unless `--force`. `--password-stdin` reads one line instead and skips the +confirmation — for scripted provisioning, where there is nobody to mistype. + +```bash +opencreds unlock # prints a session token to export +opencreds unlock --persist [--timeout 15] +opencreds lock # drops a persisted session +opencreds status # vault present? locked? counts by type +``` + +Unlocking has two shapes, and the difference is a flag rather than a default +because it is a real trade: + +- **Token (default).** `unlock` prints `export OPENCREDS_SESSION="…"`. Nothing + touches disk and the session 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. A readable user key on disk is + the vault; the command says so when you use it, and `lock` removes it. + +`status` is the one command that works locked. It reports counts and never +values, because counts are already observable to whoever holds the storage. + +```bash +opencreds recover # unlock with the recovery key, set a new password +``` + +A password change re-wraps the user key. Not one item is re-encrypted, which is +why it is instant on a vault of any size. + +## Items + +```bash +opencreds add --name [type flags…] +opencreds list [--type ] [--folder ] [--search ] [--json] +opencreds get [--field ] [--reveal] +opencreds edit [flags…] +opencreds rm [--purge] +opencreds restore +``` + +`list` and `get` MUST NOT print secret values by default. `get` prints the item +with every secret field masked; `--reveal` prints one field named by `--field`, +so revealing is always a deliberate act naming a single value. `--json` output is +masked identically — a pipeline is not an authorization. + +Type flags follow the field group names, kebab-cased: +`--username`, `--password`, `--totp`, `--url`, +`--cardholder-name`, `--number`, `--exp-month`, `--exp-year`, `--code`, +`--first-name`, `--last-name`, `--email`, `--phone`, `--address1`, …, +`--key-type`, `--algorithm`, `--public-key`, `--private-key`, `--file`, `--path`, `--mode`, +`--provider`, `--account-id`, `--handle`, `--access-token`, `--refresh-token`, `--scope`. + +`--password -` and every other secret flag read from stdin when given `-`, so a +secret need not appear in the shell history or the process list. + +## Database + +```bash +opencreds export [--out vault.opencreds] [--passphrase-stdin] +opencreds export --plaintext --out vault.json --yes +opencreds export --format bitwarden-csv --out vault.csv --yes + +opencreds import [--dry-run] [--merge skip|replace|duplicate] +opencreds import --source bitwarden|onepassword|chrome|lastpass|keepass +``` + +`export` writes the encrypted form. `--plaintext` prints what it is about to do +and exits 4 without `--yes`. + +`import` with `--dry-run` reports counts by type, folders to be created, +duplicates detected and rows that could not be mapped, and writes nothing. A +manifest mismatch exits 3 and writes nothing regardless of flags. + +Output of a dry run: + +``` +opencreds import vault.opencreds --dry-run + + Source vault.opencreds (opencreds 0.1, encrypted, namespace opencreds) + Exported 2026-08-29T18:00:00.000Z by @logicsrc/opencreds 0.1.0 + Manifest verified — 42 items, 3 folders + + login 38 2 already present (skip) + card 2 + key 1 + account 1 + + Folders Work, Personal (new), Archive + Skipped 0 + + Nothing written. Re-run without --dry-run to import. +``` + +## Validation + +```bash +opencreds validate # a database, or a plaintext item document +opencreds validate --stdin +``` + +Exits 0 when the document conforms, 2 when it does not, and prints one diagnostic +per failure with a JSON pointer into the document: + +``` +/items/17/login/uris/0/match "fuzzy" is not a valid match rule +/manifest/itemCount says 42, payload has 41 +``` + +## Conformance + +```bash +opencreds conformance # a table, one row per requirement +opencreds conformance --json # the report, for CI +opencreds conformance --emit-fixtures # generate the fixture set +``` + +Runs the suite against this implementation and reports each requirement as pass, +fail or skip. Exits 2 when a MUST does not pass, so it can gate CI directly. An +implementation claiming conformance SHOULD run it there. + +`--emit-fixtures` writes the generated fixture set, so another implementation +can be tested against exactly what this one produces and accepts. See +[conformance.md](./conformance.md). + +## What the CLI never does + +- It never prints a secret value except through `get --reveal --field`. +- It never writes a plaintext file without an explicit flag and a confirmation. +- It never sends anything anywhere. There is no telemetry, no account, and no + network call in any command listed on this page. diff --git a/docs/opencreds/conformance.md b/docs/opencreds/conformance.md new file mode 100644 index 0000000..f1f0663 --- /dev/null +++ b/docs/opencreds/conformance.md @@ -0,0 +1,138 @@ +# OpenCreds conformance + +An implementation claims conformance by satisfying the requirements below and +passing the fixture suite. Run it with `opencreds conformance`. + +## Requirement checklist + +### Items + +| # | Requirement | Level | +| --- | --- | --- | +| C1 | Reads and writes all six item types with their field groups. | MUST | +| C2 | Stamps `v`, `id`, `type`, `name`, `createdAt`, `updatedAt` on every item. | MUST | +| C3 | Preserves unknown top-level item fields on round trip. | MUST | +| C4 | Distinguishes an empty-string field from an absent one, both directions. | MUST | +| C5 | Caps password history at 20 entries, newest first. | MUST | +| C6 | Rejects a field group that does not match the item's `type`. | MUST | +| C7 | Round-trips attachment references without storing blobs. | SHOULD | + +### Crypto + +| # | Requirement | Level | +| --- | --- | --- | +| C10 | AES-256-GCM with a fresh 96-bit IV per encryption. | MUST | +| C11 | Binds `:vault:item::` as AAD; a swapped ciphertext fails. | MUST | +| C12 | Verifies the decrypted `id` against the envelope `id`. | MUST | +| C13 | Refuses to derive below 100,000 PBKDF2 iterations. | MUST | +| C14 | Derives wrap, auth and recovery keys under distinct HKDF labels. | MUST | +| C15 | Generates the user key randomly; a password change re-wraps, not re-encrypts. | MUST | +| C16 | Returns partial results with a failure list when one item fails to decrypt. | MUST | +| C17 | Rejects an unregistered namespace unless explicitly opted in. | MUST | +| C18 | Refuses a vault whose `profile` it does not implement. | MUST | +| C19 | Supports the `team` profile. | MAY | + +### Database + +| # | Requirement | Level | +| --- | --- | --- | +| C20 | Writes the encrypted form by default. | MUST | +| C21 | Binds the header as AAD, so the manifest is authenticated. | MUST | +| C22 | Recomputes and verifies `itemCount`, `types`, `folderCount`, `digest`. | MUST | +| C23 | Writes nothing on a manifest mismatch. | MUST | +| C24 | Requires an explicit opt-in for the plaintext form. | MUST | +| C25 | Writes `protected: false` in a plaintext file's header. | MUST | +| C26 | Export → import → export produces byte-identical item records. | MUST | +| C27 | Does not restamp `createdAt` / `updatedAt` on import. | MUST | +| C28 | Reports per-strategy merge outcomes rather than one total. | SHOULD | + +### CLI + +| # | Requirement | Level | +| --- | --- | --- | +| C30 | Exit codes per [cli.md](./cli.md#exit-codes). | MUST | +| C31 | Never prints a secret value except `get --reveal --field`. | MUST | +| C32 | Masks secrets identically in `--json` output. | MUST | +| C33 | `--dry-run` writes nothing. | MUST | +| C34 | `status` works while locked and reports counts only. | SHOULD | + +### Importers + +| # | Requirement | Level | +| --- | --- | --- | +| C40 | CSV reader handles quotes, escaped quotes, embedded newlines and commas, CRLF, and a BOM. | MUST | +| C41 | Reports unmappable rows with row number and reason; never drops silently. | MUST | +| C42 | Detects sources most-specific first. | MUST | + +## Running the suite + +```bash +opencreds conformance # a table, one row per requirement +opencreds conformance --json # the report, for CI +``` + +Every requirement above with a C-number in the Items, Crypto, Database and +Importers tables is executed. The CLI requirements (C30–C34) are asserted by the +reference implementation's own end-to-end tests, which drive the real binary +through a child process — a masked value that is only masked in the library is +not masked — rather than by this command, which cannot meaningfully check its +own exit codes. + +## Fixtures + +Fixtures are **generated**, not hand-written: + +```bash +opencreds conformance --emit-fixtures ./fixtures +``` + +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. Emit them from the reference +implementation and test your own code against exactly what it accepts. + +| Path | Holds | +| --- | --- | +| `README.txt` | The fixture passphrase and what each directory is for. | +| `items/one-of-each.json` | One valid item per type — C1, C2. | +| `items/history-cap.json` | 25 changes in, 20 entries out, newest kept — C5. | +| `items/unknown-fields.json` | A v1 item carrying a field from a later version — C3. | +| `vault/meta.json` | Vault metadata; opens with the fixture passphrase. | +| `vault/envelopes.json` | One encrypted envelope per item type. | +| `vault/user-key.txt` | The base64 key those envelopes are under. | +| `database/encrypted.opencreds` | A six-item encrypted database — C20–C22. | +| `database/plaintext.json` | The same vault, unprotected — C25. | +| `invalid/wrong-group.json` | A `card` group on a `login` item — C6. | +| `invalid/weak-kdf.json` | `kdfIterations: 1` — C13. | +| `invalid/unknown-namespace.json` | An unregistered namespace — C17. | +| `invalid/short-payload.json` | A plaintext database missing three items — C22. | +| `invalid/tampered-manifest.opencreds` | An edited `itemCount` — C21. | + +Everything under `invalid/` MUST be rejected. The fixture passphrase is +`opencreds-fixture`; the fixture vaults derive at 100,000 iterations so a test +run is not dominated by PBKDF2. + +## Reporting + +`opencreds conformance --json` emits: + +```json +{ + "type": "opencreds.conformance_report", + "opencreds": "0.1", + "implementation": { "name": "@logicsrc/opencreds", "version": "0.1.0" }, + "results": [ + { "id": "C11", "level": "MUST", "title": "Binds the item id as AAD, so a swapped ciphertext fails", "status": "pass" } + ], + "summary": { "pass": 29, "fail": 0, "skip": 1 }, + "conformant": true +} +``` + +`conformant` is true only when every MUST passes. A skipped MAY does not affect +it; a skipped or failed MUST does. The command exits 2 when the report is not +conformant, 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. diff --git a/docs/opencreds/crypto.md b/docs/opencreds/crypto.md new file mode 100644 index 0000000..5064778 --- /dev/null +++ b/docs/opencreds/crypto.md @@ -0,0 +1,147 @@ +# OpenCreds cryptography + +The normative rules are [spec.md §4](./spec.md#4-the-vault). This page explains +why the construction is shaped the way it is, and what an implementer will get +wrong if they skip a step. + +## The shape + +``` +master password + │ PBKDF2-HMAC-SHA256(salt, iterations) ← the only expensive step + ▼ + master key (32 bytes) never encrypts anything itself + │ + ├─ HKDF(":vault:wrap:v1") → wrap key → AES-GCM → protected user key + ├─ HKDF(":vault:auth:v1") → auth hash → server (hashed again there) + └─ HKDF(":vault:recovery:v1") → recovery wrap → recovery blob + + user key (32 random bytes) ← what every item is actually encrypted under + │ + └─ AES-256-GCM(iv, item JSON, AAD = ":vault:item::") +``` + +## Why the user key is random, not derived + +Because a master password change must not be a re-encryption of the vault. The +user key is generated once and wrapped; changing the password re-wraps 32 bytes. +Derive item keys from the password instead and every password change rewrites +every item — which, on a vault of any size, is a long window in which a partial +failure leaves half the vault openable by the old password and half by the new. + +It also means the recovery path costs nothing extra: a second wrapping of the +same 32 bytes under a random recovery key, and a forgotten password is +survivable without the server learning anything it did not already hold. + +## Why the auth hash cannot decrypt + +The wrap key and the auth hash come out of the same master key through HKDF with +*different labels*. HKDF's guarantee is exactly this: outputs under distinct +info strings are computationally independent. A server holding every auth hash +it has ever seen holds nothing that helps it derive a wrap key. + +That is what allows the auth hash to be sent at all. A scheme that sent the +wrapping key, or anything from which it could be recovered, would be a scheme +where "the server cannot read the vault" is a promise rather than a property. + +## Why the item id is in the AAD + +Without it, a ciphertext is portable between rows. Anyone with write access to +the storage — a compromised server, an operator, a leaked backup restored +somewhere writable — could copy the ciphertext of a low-value login into the row +of a high-value one and watch what the user does next. The user unlocks, sees +the credential they expected to see under a name they trust, and uses it. + +With the id bound in, that swap fails to decrypt. The tag covers the id, and the +id is not in the ciphertext's control. + +The version is in the AAD for the same reason at a different scale: it stops a +v2 record from being replayed as a v1 record once v2 exists. + +## Why the KDF floor is a client-side check + +KDF parameters are stored with the vault and, in a hosted deployment, are served +to the client at unlock time. That makes them attacker-controlled the moment the +server is compromised. A client that trusts `{"iterations": 1}` performs one +round of PBKDF2, derives an auth hash almost free, and hands an attacker who has +been capturing auth hashes an offline guessing exercise with no work factor at +all. + +So the floor is enforced where it matters — in the client, before deriving — +and, in a database-backed deployment, again as a constraint on the column. +Defence in depth on the one value the user cannot see. + +## Namespaces + +Every label above is prefixed by the vault's declared `namespace`. This is not +decoration. A label is compiled into the additional authenticated data of every +ciphertext a vault has ever written, and into the HKDF derivation of its keys. +Change a label string and every vault in the world that used it becomes +undecryptable — not corrupted, not recoverable, undecryptable. + +So labels are append-only in the strongest sense available: superseded by a new +`:v2` label, never edited. And because MarkSyncr's vault shipped with +`marksyncr:vault:*` labels before this specification existed, the prefix is +carried as a per-vault property rather than fixed by the spec. A deployed vault +declares `"namespace": "marksyncr"` and is conformant; a new one uses +`opencreds`. + +Registered: `opencreds`, `marksyncr`. Pattern: `^[a-z][a-z0-9-]{1,31}$`. + +An implementation MUST reject an unregistered namespace on import unless the +operator opts in, because accepting an arbitrary prefix is accepting an +arbitrary derivation. + +## Profiles + +### `user` + +Everything above. One person, one master password. + +### `team` + +The vault key is random and is wrapped to each member with `crypto_box_seal` +(X25519 anonymous sealed box) against that member's public key. The server holds +one wrapped key per member and never the key. Granting access is an existing +member unwrapping with their secret key and re-sealing to the new member's public +key — the plaintext key exists only in memory, on a machine that was already +authorized. + +`logicsrc credentials` implements this today for `.env` secrets and SSH keys; see +[credential-sharing.md](../credential-sharing.md). OpenCreds adds nothing to it +except the observation that the thing being wrapped can be a vault of items +rather than a bag of strings. + +The item envelope is identical under both profiles. That is the whole point: an +item exported from a personal vault imports into a team vault without +re-encoding, because only the key management differed. + +**Threat model difference, stated plainly.** In the `team` profile, every member +who holds the vault key can read every item in it. Revoking a member means +rotating the key and re-encrypting, because the key they held is a key they may +have kept. Partial sharing is not a feature of a shared key; it is a second +vault. + +## Randomness + +Every IV, salt, user key and recovery key MUST come from a cryptographic RNG +(`crypto.getRandomValues`, `crypto.randomBytes`). An IV MUST NOT be reused under +one key — with GCM, a repeated IV under the same key is not a weakness, it is a +break, and it leaks the XOR of two plaintexts along with the authentication +subkey. + +Because the user key is per vault and IVs are per write, the safe construction +is simply: generate a fresh 12-byte IV on every single encryption, never derive +it, never count with it. + +## What is not covered + +- **Key stretching for the export passphrase** uses the same PBKDF2 parameters + as a vault. An export passphrase is typed once and often weaker than a master + password; an implementation SHOULD say so rather than silently accepting four + characters. +- **Memory hygiene.** Zeroing key material after use is out of scope for the + format and worth doing anyway where the runtime allows it. In a browser it + mostly does not. +- **Side channels.** Comparisons of secret-derived values (auth hashes, tags) + MUST be constant-time. Everything else in the format compares public data. diff --git a/docs/opencreds/database.md b/docs/opencreds/database.md new file mode 100644 index 0000000..86f762e --- /dev/null +++ b/docs/opencreds/database.md @@ -0,0 +1,127 @@ +# The OpenCreds portable database + +Normative rules: [spec.md §5](./spec.md#5-the-portable-database). + +A database is a vault as one file. It is what you hand to another product, what +you keep as a backup, and what an implementation reads to import. + +## Encrypted by default + +```json +{ + "opencreds": "0.1", + "type": "opencreds.database", + "protected": true, + "namespace": "opencreds", + "exportedAt": "2026-08-29T18:00:00.000Z", + "generator": { "name": "@logicsrc/opencreds", "version": "0.1.0" }, + "kdf": { "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "…" }, + "manifest": { + "itemCount": 42, + "types": { "login": 38, "card": 2, "key": 1, "account": 1 }, + "folderCount": 3, + "digest": "…" + }, + "iv": "…", + "ciphertext": "…" +} +``` + +Everything above `iv` is readable without the passphrase, and all of it is +authenticated by the tag on `ciphertext` — the header is the AAD. So the counts +can be shown in a preview before anyone types a passphrase, and they cannot be +lied about. + +## The export key is not the vault key + +An export is encrypted under a key derived from an **export passphrase**, not +under 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. + +``` +export passphrase ─PBKDF2(fresh salt, iterations)─► HKDF(":database:v1") ─► export key +``` + +An implementation MAY accept a raw 32-byte key instead, for machine-to-machine +transfer; then `kdf` is absent from the file. + +## The manifest is the integrity check + +``` +digest = base64( SHA-256( item ids, sorted lexicographically, joined by "\n" ) ) +``` + +After decrypting, recompute `itemCount`, `types`, `folderCount` and `digest`. +Any disagreement fails the import. + +This is the difference between an import you can trust and a CSV. A CSV that was +truncated at 3,000 rows imports 3,000 rows and reports success. A database that +was truncated does not decrypt at all; one that was edited after decryption +fails its digest. There is no state in which an implementation reports a +complete import of an incomplete file. + +## The plaintext form + +```json +{ + "opencreds": "0.1", + "type": "opencreds.database", + "protected": false, + "namespace": "opencreds", + "exportedAt": "…", + "generator": { … }, + "manifest": { … }, + "folders": [ { "id": "…", "name": "Work" } ], + "items": [ … ] +} +``` + +Every secret you own, 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 "give me the CSV" is an export format +people work around with a script that is worse. So it is specified, and it is +made loud: + +- `protected: false` sits in the header, so tooling can identify the file + without parsing it. +- The CLI requires `--plaintext` and a confirmation. +- The file is written `0600` where the platform has modes. +- The manifest is still present and still verified. Unauthenticated, but it + still catches a truncated copy or a half-finished edit. + +## Merging on import + +The spec does not mandate a merge strategy, but it names the three that exist +and what each does to an id: + +| Strategy | Behaviour | +| --- | --- | +| `skip` | An incoming item whose id already exists is skipped. The safe default. | +| `replace` | The existing item is overwritten. | +| `duplicate` | The incoming item is given a fresh id and both are kept. | + +`duplicate` is the only one that never loses data and the only one that can +double a vault. An implementation SHOULD default to `skip` and SHOULD report the +count of each outcome rather than a single "imported N". + +Folder ids collide the same way. An incoming folder whose id exists and whose +name differs is a conflict; the reference implementation keeps the existing +folder and remaps incoming `folderId`s onto it. + +## Round-tripping + +A conforming export → import → export cycle MUST produce byte-identical item +records. Specifically: + +- Unknown top-level item fields survive. An item written by a future version + passes through an older implementation without losing what it did not + understand. +- Empty-string fields are not dropped and not invented. `""` and absent are + distinguishable and both are preserved as they arrived. +- Timestamps are not restamped. `createdAt` and `updatedAt` belong to the item, + not to the transfer; an importer that touches them destroys the only evidence + of when a password was last rotated. + +The `exportedAt` and `generator` of the *file* do change, of course. They +describe the transfer, which is the one thing that is genuinely new each time. diff --git a/docs/opencreds/faq.md b/docs/opencreds/faq.md new file mode 100644 index 0000000..4c04511 --- /dev/null +++ b/docs/opencreds/faq.md @@ -0,0 +1,91 @@ +# OpenCreds FAQ + +### Why not just use the Bitwarden JSON export? + +It is the closest thing that exists, and it is a product's export format rather +than a specification: undocumented, versioned by the product, plaintext-only in +practice, and with no integrity check. OpenCreds keeps the parts that work — the +item-with-a-type-group shape, the type codes — and adds the parts that are +missing: an encrypted form as the default, an authenticated manifest, a declared +crypto construction, published schemas and a conformance suite. + +The type codes 1–4 are deliberately the same. Compatibility is cheaper than +elegance. + +### Why is there a plaintext form at all? + +Because people move *to* products that read nothing else, and a format that +refuses to express that gets worked around with a script that is worse — no +warning, no file mode, no label. Specifying it means it can be made loud: an +explicit flag, a confirmation, `protected: false` in the header, and an owner-only +file mode. + +### Why PBKDF2 rather than Argon2id? + +Argon2id is better and needs WASM in a browser, which means adding +`wasm-unsafe-eval` to an extension's content security policy. That is a real cost +paid by every user of a product to benefit the KDF. The parameters are carried +per vault specifically so the switch is a migration later rather than a break +now, and `argon2id` is already a registered value. + +### Why is `namespace` a property instead of a constant? + +Because MarkSyncr's vault shipped first, with `marksyncr:vault:*` labels baked +into the additional authenticated data of every ciphertext it has written. +Changing a label does not migrate a vault; it makes it undecryptable. Carrying +the prefix as data is what lets a deployed vault be conformant without +re-encrypting a single item. New vaults use `opencreds`. + +### Why is an `account` not a `login`? + +A login is what a person types at a sign-in form. An account is what a machine +presents to an API. They expire differently, they are revoked differently, and +they are rotated by different actors. Conflating them is how a rotated refresh +token ends up in a password history array, and how an expiry date ends up in a +notes field. + +### Can I keep `.env` secrets in an OpenCreds vault? + +Yes — that is what `key` items with `keyType: "env"` are. The variable name is +the item `name` and the secret is `key.value`. `logicsrc credentials` moves those +values *between providers*; OpenCreds is what one looks like when it is stored +rather than moved. + +### Does this replace `logicsrc credentials`? + +No. Credential Sharing is a sync spec: providers, plans, diffs, dry runs, +rollback and audit. OpenCreds is a record and a file. They meet at the `key` +item — a synced `.env` entry, stored — and at the `team` profile, which is the +credential-sharing key scheme applied to a vault of items. + +### What happens if two devices edit the same item? + +The specification does not say, because it does not specify storage. The +reference implementation and both Profullstack products store one row per item +with a monotonic revision, so a client that writes with a stale revision is +rejected rather than overwriting. That is a recommendation, not a requirement. + +### Why cap password history at twenty? + +The item is one blob, rewritten in full on every save. An uncapped history array +grows that blob without bound, and the growth is invisible until a vault sync +starts timing out. Twenty entries is more history than anyone consults. + +### Can I import a vault without the passphrase, just to see what is in it? + +You can see the header: version, namespace, export time, generator, and the +manifest — item count, counts by type, folder count. That is enough for a +preview and it is authenticated, so it cannot be lied about. Nothing else is +readable, which is the point. + +### Is there a hosted OpenCreds service? + +No, and the specification does not describe one. A conforming vault is a file +and a key. Products built on it may be hosted; the standard is not. + +### How do I claim conformance? + +Run `opencreds conformance` against the published fixtures, pass every MUST, and +say which profile and namespace you implement. The suite is in +`packages/opencreds/fixtures/` and ships in the published package, so nobody has +to read our source to verify their own implementation. diff --git a/docs/opencreds/interop.md b/docs/opencreds/interop.md new file mode 100644 index 0000000..926ba1c --- /dev/null +++ b/docs/opencreds/interop.md @@ -0,0 +1,129 @@ +# Importing from other products + +OpenCreds is meant to be arrived at, not just left from. This page specifies the +mappings from the exports people actually have. + +Every source below exports CSV, so the work is one correct CSV reader plus a +column mapping per product. The reader matters more than the mappings: a naive +`split(',')` mangles any export containing a note with a comma in it, which is +most of them. + +## The reader + +A conforming CSV reader MUST handle quoted fields, escaped quotes (`""`), +embedded newlines inside quotes, embedded commas, both CRLF and LF, and a +leading UTF-8 BOM. Chrome and Excel both emit a BOM, and unhandled it becomes +part of the first header name and breaks every column lookup in the file. + +Header names are compared lowercased and trimmed, because column casing differs +between versions of the same product. + +## Detection + +An importer SHOULD identify the source from the header row so a person can drop +in a file without first telling us where it came from. Detection is ordered +most-specific first: Chrome's columns are a subset of 1Password's, so asking in +the wrong order misidentifies every Chrome export. + +Order: `bitwarden`, `lastpass`, `keepass`, `onepassword`, `chrome`. + +## Mappings + +### Bitwarden + +Header contains `login_uri` or `login_password`. Row `type` selects the item type. + +| Bitwarden column | OpenCreds | +| --- | --- | +| `name` | `name` | +| `notes` | `notes` | +| `folder` | folder by name | +| `favorite` | `favorite` (`1` → true) | +| `login_username` | `login.username` | +| `login_password` | `login.password` | +| `login_totp` | `login.totp` | +| `login_uri` | `login.uris[0].uri`, `match: "domain"` | +| `card_*` | `card.*` | +| `identity_*` | `identity.*` | +| `type: securenote` | `note` | + +### 1Password + +Header contains `url`, `username` and `type`. + +| 1Password column | OpenCreds | +| --- | --- | +| `title` | `name` | +| `url`/`website` | `login.uris[0].uri` | +| `username`, `password` | `login.*` | +| `otpauth` | `login.totp` | +| `notes` | `notes` | + +### Chrome + +Header contains `url`, `username`, `password`. Logins only. + +| Chrome column | OpenCreds | +| --- | --- | +| `name` | `name`, falling back to the URL host | +| `url` | `login.uris[0].uri` | +| `username`, `password` | `login.*` | +| `note` | `notes` | + +### LastPass + +Header contains `url` and `grouping`. LastPass writes `http://sn` in `url` for +secure notes, which is the only reliable way to tell one from a login. + +| LastPass column | OpenCreds | +| --- | --- | +| `name` | `name` | +| `grouping` | folder by name | +| `url` | `login.uris[0].uri`, unless `http://sn` | +| `username`, `password` | `login.*` | +| `totp` | `login.totp` | +| `extra` | `notes` | +| `fav` | `favorite` | + +### KeePass (CSV export) + +Header contains `account` and `login name`, or `group` and `password`. + +| KeePass column | OpenCreds | +| --- | --- | +| `account`/`title` | `name` | +| `login name`/`user name` | `login.username` | +| `password` | `login.password` | +| `web site`/`url` | `login.uris[0].uri` | +| `comments`/`notes` | `notes` | +| `group` | folder by name | + +## Rules that apply to every importer + +**Report, never drop.** A row that cannot be mapped is returned in a `skipped` +list with its row number and a reason. An import that silently loses credentials +is worse than one that says what it could not read — the person still has the +source file, and only knows to go back for it if they are told. + +**Name from the host when the export had none.** Chrome in particular writes +rows with an empty name; `github.com` is a better label than a blank line. + +**An empty row is not a failure.** A login with no username, no password and no +name is a trailing blank line. It is skipped with the reason `Empty row`, which +is different from `could not map` and should read differently in a report. + +**Nothing here touches crypto or the network.** An importer turns text into +plain item objects. The caller encrypts them. That separation is what lets the +same importer run in a browser extension's service worker and in a CLI. + +## Going the other way + +`opencreds export --format bitwarden-csv` writes a Bitwarden-shaped CSV, because +that is the format most other products import best. It is a plaintext export and +carries every warning that implies — see +[database.md](./database.md#the-plaintext-form). + +The lossy fields are named in the output rather than discovered later: password +history, custom fields, attachments, URI match rules, `key` items and `account` +items have no column in any product's CSV. The CLI prints what it dropped and +the count for each. diff --git a/docs/opencreds/item-model.md b/docs/opencreds/item-model.md new file mode 100644 index 0000000..2ed4873 --- /dev/null +++ b/docs/opencreds/item-model.md @@ -0,0 +1,180 @@ +# The OpenCreds item model + +One record, six types, one field group each. This page specifies the groups +field by field. The record shape around them is [spec.md §3](./spec.md#3-the-item). + +Every field in every group is a string unless stated otherwise, and every field +is OPTIONAL with an empty string as its default. A vault holds half-filled +records — someone knows the card number and not the issuing bank — and a model +that requires fields produces importers that invent them. + +## login (code 1) + +| Field | Type | Notes | +| --- | --- | --- | +| `username` | string | | +| `password` | string | | +| `totp` | string | An `otpauth://` URI, or a bare base32 seed. Store the URI where you have it: it carries the algorithm, digits and period, and a bare seed loses them. | +| `uris` | array | Matching URIs, see below. | + +```json +{ "uri": "https://github.com", "match": "domain" } +``` + +`match` MUST be one of `domain`, `host`, `startsWith`, `exact`, `regex`, or +`never`. It is carried so that a move does not silently widen where a credential +will be offered; an implementation that does not autofill still round-trips it. + +`login` is the only type with `history` ([spec.md §3.5](./spec.md#35-password-history)). + +## card (code 2) + +| Field | Notes | +| --- | --- | +| `cardholderName` | | +| `brand` | `Visa`, `Mastercard`, `Amex`, … Free text; issuers add brands. | +| `number` | Full PAN. | +| `expMonth` | `1`–`12`, no leading zero required. | +| `expYear` | Four digits. Two-digit years from an import are expanded to 20xx. | +| `code` | CVV/CVC. | + +## identity (code 3) + +| Field | Notes | +| --- | --- | +| `title` | Mr, Ms, Dr, … | +| `firstName`, `middleName`, `lastName` | | +| `username` | An identity's handle, distinct from a login's. | +| `company` | | +| `email`, `phone` | | +| `address1`, `address2`, `address3` | | +| `city`, `state`, `postalCode`, `country` | | +| `ssn` | National identity number. Named `ssn` for import compatibility; it is not US-specific. | +| `passportNumber` | | +| `licenseNumber` | | + +## note (code 4) + +No field group. The content is the record's `notes` field. A `note` item that +also carries custom `fields` is valid and common — it is how people store the +things a vault has no type for. + +## key (code 5) + +Introduced by OpenCreds. Covers SSH keys, PGP keys, API tokens, certificates, +and the `.env` secrets that `logicsrc credentials` synchronizes. + +| Field | Notes | +| --- | --- | +| `keyType` | One of `ssh`, `pgp`, `api`, `symmetric`, `certificate`, `env`. | +| `algorithm` | `ed25519`, `rsa-4096`, `ecdsa-p256`, … | +| `publicKey` | Armoured/OpenSSH public key text. | +| `privateKey` | Armoured/PEM private key text. | +| `passphrase` | The private key's own passphrase, where it has one. | +| `fingerprint` | `SHA256:…` — a public, non-secret identifier. | +| `value` | The secret for key types that are one opaque string (`api`, `env`, `symmetric`). | +| `path` | Where the key belongs on disk, e.g. `~/.ssh/id_ed25519`. | +| `mode` | POSIX mode as an octal string, e.g. `"0600"`. | +| `expiresAt` | RFC 3339, where the key expires. | + +`path` and `mode` exist so a restore is total: a private key written back with +the wrong mode is a key `ssh` will refuse to use, and a key written to the wrong +path is a key nothing finds. They carry the same information as the +self-describing envelope the `ssh` credential provider already writes. + +An `.env` secret becomes `{ "keyType": "env", "value": "…" }` with the variable +name as the item `name`. That is the bridge between the two specs: Credential +Sharing moves a key/value pair between providers, OpenCreds is what it looks +like when stored. + +## account (code 6) + +Introduced by OpenCreds. A provider account and the tokens that authorize acting +as it — a connected Google account, a social account, a service account. + +| Field | Notes | +| --- | --- | +| `provider` | `google`, `github`, `x`, `stripe`, … An OpenOntology entity id where one is in use. | +| `accountId` | The provider's own id for the account. | +| `handle` | The username or handle at that provider. | +| `email` | | +| `accessToken` | | +| `refreshToken` | | +| `tokenType` | `bearer`, … | +| `scopes` | Array of strings. | +| `expiresAt` | RFC 3339 expiry of `accessToken`. | +| `environment` | `production`, `sandbox`, … A test key and a live key look identical and are not. | + +An `account` is deliberately not a `login`. A login is what a *person* types at +a sign-in form; an account is what a *machine* presents to an API. They expire +differently, they are revoked differently, and conflating them is how a rotated +refresh token ends up in a password history array. + +## Folders + +A vault MAY carry folders. A folder is `{ "id": "", "name": "Work" }` and +is referenced by an item's `folderId`. Folders are flat: a name MAY contain `/` +and an implementation MAY render that as a hierarchy, but the model does not +nest, because every product that nests them disagrees about what a move does. + +Folder names are **not** encrypted by the item envelope — they live in the +database payload, which is encrypted as a whole, and in a vault's own storage +they are wherever that implementation puts them. An implementation that stores +folder names in the clear MUST say so; a folder list is a good description of +someone's life. + +## Worked examples + +A login with history: + +```json +{ + "v": 1, "id": "6f1e7b3a-1f4e-4f0f-9a1d-6a2f0b6f8d21", "type": "login", + "name": "GitHub", "favorite": true, "folderId": null, "notes": "", + "login": { + "username": "anthony", + "password": "correct-horse-battery-staple", + "totp": "otpauth://totp/GitHub:anthony?secret=JBSWY3DPEHPK3PXP&issuer=GitHub", + "uris": [{ "uri": "https://github.com", "match": "domain" }] + }, + "history": [{ "password": "hunter2", "changedAt": "2026-01-04T09:12:00.000Z" }], + "createdAt": "2025-11-02T10:00:00.000Z", + "updatedAt": "2026-01-04T09:12:00.000Z" +} +``` + +An SSH deploy key: + +```json +{ + "v": 1, "id": "0c0f7a2e-9d1a-4f6e-b2b7-1f3d5a7c9e11", "type": "key", + "name": "deploy@railway", "notes": "Rotated quarterly", + "key": { + "keyType": "ssh", "algorithm": "ed25519", + "publicKey": "ssh-ed25519 AAAAC3Nza… deploy@railway", + "privateKey": "", + "passphrase": "", "fingerprint": "SHA256:9Vt…", + "path": "~/.ssh/id_ed25519_railway", "mode": "0600", "expiresAt": "" + }, + "createdAt": "2026-03-01T00:00:00.000Z", + "updatedAt": "2026-06-01T00:00:00.000Z" +} +``` + +A connected account: + +```json +{ + "v": 1, "id": "3a8c1d55-77e2-4b0a-9d3c-2b6e5f8a1c04", "type": "account", + "name": "Stripe (live)", + "account": { + "provider": "stripe", "accountId": "acct_1P…", "handle": "profullstack", + "email": "billing@profullstack.com", + "accessToken": "", "refreshToken": "", "tokenType": "bearer", + "scopes": ["charges:write", "customers:read"], + "expiresAt": "", "environment": "production" + }, + "createdAt": "2026-02-14T00:00:00.000Z", + "updatedAt": "2026-02-14T00:00:00.000Z" +} +``` diff --git a/docs/opencreds/security.md b/docs/opencreds/security.md new file mode 100644 index 0000000..4a410fb --- /dev/null +++ b/docs/opencreds/security.md @@ -0,0 +1,120 @@ +# OpenCreds security model + +## What the format protects + +An attacker holding **the storage** — the database, its backups, an operator's +console, a restored snapshot — learns: + +- how many items a vault holds, and how many of each type; +- when each item was created and last changed; +- the vault's KDF parameters, salt, and wrapped keys; +- folder ids, and folder names in implementations that store them in the clear. + +They do not learn any field of any item. The user key is never present in +storage in a form the storage can open. + +An attacker holding **the storage and write access** additionally cannot move a +ciphertext between rows: the item id is bound as additional authenticated data, +so a swapped ciphertext fails to decrypt rather than showing the wrong +credential under a trusted name. + +An attacker holding **the auth hash** — every one ever sent, in full — cannot +derive the wrapping key. The two come out of the same master key under distinct +HKDF labels. + +## What it does not protect + +**Metadata.** Item counts by type are visible by design, because the type code +is stored in plaintext so a server can filter and paginate without decrypting. +That means a server learns you hold forty logins and two cards. Hiding it costs +padding and blind indexes and buys less than it appears to; the specification +states the leak rather than obscuring it. + +**A weak master password.** PBKDF2 at 600,000 iterations raises the cost of a +guess; it does not make a common password safe. Nothing in the format can. + +**A compromised client.** Every value is decrypted somewhere. An attacker who +runs code in the process that holds the user key has the vault, and no format +choice changes that. + +**A plaintext export.** It is exactly what it says. See below. + +**Deletion.** `deleted_at` is a trash bin, not an erasure. A purge removes the +row; whether it removes the bytes is a property of the storage engine and its +backups, not of this specification. + +## Deliberate decisions + +### The type code is plaintext + +So the server can paginate. The alternative — decrypting every row to answer +"show me page 2 of the logins" — either moves the whole vault to the client on +every read or gives the server a key. Both are worse. + +### Key material is base64 text + +Where this travels as JSON over an HTTP API, binary round-trips as an escaped +hex string and invites encoding mistakes on exactly the values that must not be +corrupted. Text that is wrong is visibly wrong. + +### One item is one blob + +Password history, custom fields and attachment keys all live inside the item's +single ciphertext. That makes history encrypted by construction rather than by a +second protected table someone can forget to protect. It costs a full rewrite of +the item on every save, which is why history is capped — an uncapped array grows +the ciphertext without bound. + +### One row per item + +Not one blob per vault. Two devices editing two *different* passwords at the +same moment must not cost anyone a credential, and with a single blob the later +write silently discards the earlier. Per-item rows with a revision make that a +detectable conflict instead of a silent loss. + +### The KDF floor is enforced client-side + +Parameters arrive from a server. If the server is compromised, they are +attacker-controlled, and `iterations: 1` turns captured auth hashes into a free +offline attack. The client refuses below its own floor before deriving anything, +and a database-backed implementation SHOULD repeat the constraint in the schema. + +## The plaintext export + +This is the most dangerous operation in the specification, and it is specified +because the alternative is people writing worse versions of it themselves. + +Requirements, restated: + +- Never the default. +- An explicit flag, plus a confirmation. +- `"protected": false` in the header so tooling can identify the file. +- Owner-only file mode where the platform has one. +- A warning that says what it means: this file cannot be un-leaked, and every + password in it should be considered exposed if it is. + +An implementation SHOULD offer to delete the file after a successful import +elsewhere, and MUST NOT do so automatically — the person may still need it. + +## Threats specific to the `team` profile + +Every member holding the vault key reads every item in the vault. This is a +property of a shared symmetric key, not a gap in the implementation. + +Consequences worth stating in a product's own docs: + +- **Revocation requires rotation.** Removing a member's wrapped key stops them + fetching new ciphertext. It does not un-know the key they held. A member who + leaves means a new vault key and a re-encryption of every item. +- **Partial sharing is a second vault.** There is no way to share three items out + of forty under one key. Split the vault. +- **Granting is a client-side act.** An existing member unwraps and re-seals to + the new member's public key. The server can add a member to a list; it cannot + give them access, because it does not have the key. This is a feature, and it + means a grant requires an authorized member to be online. + +## Reporting + +Security issues in the specification or the reference implementation: +`security@profullstack.com`, or the security policy published at +`logicsrc.com/.well-known/security.txt`. diff --git a/docs/opencreds/spec.md b/docs/opencreds/spec.md new file mode 100644 index 0000000..d5f9ead --- /dev/null +++ b/docs/opencreds/spec.md @@ -0,0 +1,421 @@ +# The OpenCreds Specification + +Version: **0.1** (draft) +Status: draft — the wire formats below are implemented by `@logicsrc/opencreds` +and are expected to change only additively before 1.0. + +The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be +interpreted as described in RFC 2119. + +## 1. Scope + +OpenCreds specifies three things: + +1. **The item** — the record shape for a stored credential (§3). +2. **The vault** — how items are encrypted and how keys are derived (§4). +3. **The database** — the portable file a vault exports to and imports from (§5). + +It does not specify storage, synchronization, transport, autofill, or user +interface. An implementation that produces and consumes conforming items and +databases is conforming regardless of where it puts the bytes. + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **item** | One credential record: a login, a card, an identity, a note, a key or an account. | +| **vault** | A set of items sharing one *user key*. | +| **user key** | The 256-bit symmetric key that every item in a vault is encrypted under. | +| **master password** | The human secret from which a `user`-profile vault's wrapping key is derived. | +| **wrap key** | Derived from the master password; encrypts the user key. Never leaves the device. | +| **auth hash** | Derived from the master password under a different label; the only password-derived value sent to a server. | +| **recovery key** | A random 128-bit value that wraps a second copy of the user key. | +| **namespace** | The domain-separation label prefix for a vault (§4.6). | +| **profile** | How the user key is managed: `user` or `team` (§4.7). | +| **database** | A vault serialized to a single file (§5). | +| **manifest** | The authenticated inventory of a database (§5.3). | + +## 3. The item + +### 3.1 Record shape + +An item is a JSON object. Every item MUST carry: + +| Field | Type | Notes | +| --- | --- | --- | +| `v` | integer | Item schema version. `1` for this specification. | +| `id` | string | A UUID, generated by the client. See §4.4 — it is bound into the ciphertext. | +| `type` | string | One of `login`, `card`, `identity`, `note`, `key`, `account`. | +| `name` | string | Display name. MAY be empty. | +| `createdAt` | string | RFC 3339 timestamp. | +| `updatedAt` | string | RFC 3339 timestamp. | + +An item MAY carry: + +| Field | Type | Notes | +| --- | --- | --- | +| `favorite` | boolean | Defaults to `false`. | +| `folderId` | string \| null | A folder id declared in the same vault, or `null`. | +| `notes` | string | Free text. For `note` items this is the content. | +| `fields` | array | Custom fields (§3.3). | +| `attachments` | array | Attachment references (§3.4). | +| `history` | array | Password history (§3.5). `login` items only. | +| `` | object | The field group named by `type` (§3.2). | + +An item MUST NOT carry a field group other than the one named by its `type`. An +implementation reading an unknown top-level field MUST preserve it on round trip +rather than dropping it; this is what makes the format forward-compatible. + +### 3.2 Type codes and field groups + +The type code is the integer an implementation MAY store in plaintext alongside +the ciphertext so a server can filter and paginate without decrypting. + +| `type` | Code | Group | Purpose | +| --- | --- | --- | --- | +| `login` | 1 | `login` | Username, password, TOTP, matching URIs | +| `card` | 2 | `card` | Payment card | +| `identity` | 3 | `identity` | Name, address and identity document numbers | +| `note` | 4 | — | Free text only; content lives in `notes` | +| `key` | 5 | `key` | SSH/PGP/API keys, certificates, `.env` secrets | +| `account` | 6 | `account` | A provider account and its OAuth tokens | + +Codes 1–4 are fixed by MarkSyncr's deployed vault and MUST NOT be renumbered. +Codes 5 and 6 are introduced by this specification. Codes 7+ are reserved. + +The groups are specified field by field in [item-model.md](./item-model.md). + +### 3.3 Custom fields + +```json +{ "name": "Employee ID", "value": "A-4417", "type": "text", "hidden": false } +``` + +`type` MUST be one of `text`, `hidden`, `boolean`, or `linked`. A `hidden` field +is displayed masked; it is not encrypted differently — everything in the item is +already inside one ciphertext. + +### 3.4 Attachments + +An item carries attachment *references*, not bytes: + +```json +{ "id": "…", "name": "passport.pdf", "size": 148213, + "contentType": "application/pdf", "digest": "sha256-…", "key": "…" } +``` + +`key` is the base64 AES-256 key the blob was encrypted under, held inside the +item ciphertext so the blob store never sees it. An implementation that does not +store blobs MUST still round-trip the references. + +### 3.5 Password history + +```json +{ "password": "the previous value", "changedAt": "2026-08-01T12:00:00.000Z" } +``` + +Newest first. An implementation MUST cap history at 20 entries: the item blob is +rewritten on every save, and an uncapped array grows the ciphertext without +bound. History is only defined for `login` items. + +## 4. The vault + +### 4.1 Primitives + +| Purpose | Algorithm | +| --- | --- | +| Password stretching | PBKDF2-HMAC-SHA256 | +| Key derivation | HKDF-SHA256 | +| Symmetric encryption | AES-256-GCM, 96-bit IV, 128-bit tag | +| Digests | SHA-256 | + +All four are available in WebCrypto. An implementation MUST NOT substitute +another cipher for AES-GCM in this version. + +### 4.2 KDF parameters + +Parameters travel *with* the vault so they can be strengthened later without +invalidating anyone's data: + +```json +{ "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "" } +``` + +- `iterations` MUST default to 600,000 for a vault created under this version. +- A client MUST refuse to derive below 100,000 iterations. Parameters arrive + from a server, which makes them attacker-controlled if the server is + compromised: serving `iterations: 1` would turn every captured auth hash into + an offline guessing exercise with no work factor. +- `argon2id` is a REGISTERED value and is not yet specified. A client that does + not implement it MUST refuse the vault rather than fall back. + +### 4.3 Key hierarchy + +``` +master password ─PBKDF2(salt, iterations)─► master key (32 bytes) + │ + ┌────────────────────────────────┼────────────────────────────┐ + HKDF(:vault:wrap:v1) HKDF(:vault:auth:v1) HKDF(:vault:recovery:v1) + │ │ │ + wrap key ──AES-GCM──► protected user key recovery wrap key + │ │ + auth hash ──► server recovery blob +``` + +- The user key is 32 random bytes generated on the client. It is what items are + encrypted under, and it is never derived from the password — so changing the + master password re-wraps one key rather than re-encrypting every item. +- The master key is never used to encrypt anything directly. +- The auth hash is the only password-derived value that may leave the device. A + server storing it MUST hash it again before storage. +- A recovery key is 16 random bytes, presented to the user in a grouped + base32-style encoding, and wraps a second copy of the user key. It exists so a + forgotten master password is survivable without the server learning anything. + +### 4.4 The item envelope + +To encrypt an item: + +1. Serialize the item to UTF-8 JSON with `v` present. +2. Generate a fresh 96-bit IV. An IV MUST NOT be reused under one key. +3. Compute the additional authenticated data: + `AAD = UTF8(":vault:item::")` +4. `AES-GCM(userKey, iv, plaintext, AAD)`. + +The stored envelope is: + +```json +{ "id": "", "type": 1, "ciphertext": "", "iv": "" } +``` + +Binding the id means a ciphertext cannot be moved between rows without +decryption failing. On decryption an implementation MUST additionally verify +that the decrypted `id` equals the envelope `id`; the AAD already makes a +mismatch unreachable, and the check costs nothing. + +A vault read MUST be tolerant per item: when one item fails to decrypt, the +implementation MUST return the items that succeeded together with a list of the +ids that failed, rather than failing the whole read. A single corrupt row must +not hide someone's vault from them. + +### 4.5 Vault metadata + +```json +{ + "opencreds": "0.1", + "namespace": "opencreds", + "profile": "user", + "kdf": "pbkdf2-sha256", + "kdfIterations": 600000, + "kdfSalt": "", + "protectedUserKey": "", + "protectedUserKeyIv": "", + "recoveryKeyBlob": "", + "recoveryKeyIv": "", + "authHash": "", + "createdAt": "…", + "updatedAt": "…" +} +``` + +Key material is base64 text rather than binary. Where this travels as JSON over +an HTTP API, binary round-trips as an escaped hex string and invites encoding +mistakes on exactly the values that must not be corrupted. + +### 4.6 Namespaces + +Every domain-separation label in this specification is prefixed by the vault's +`namespace`: + +``` +:vault:wrap:v1 +:vault:auth:v1 +:vault:recovery:v1 +:vault:item:: +:database:v1 +``` + +A vault created under this specification MUST use the namespace `opencreds`. A +vault created before it MAY declare its own — `marksyncr` is registered — and is +conformant with that declaration. + +This exists because labels are baked into every ciphertext already written. +Editing one makes every existing vault in the world undecryptable, so a label is +append-only in the strongest sense: it can be superseded, never changed. Carrying +the prefix as data is what lets a deployed vault become conformant without +re-encrypting a single item. + +Registered namespaces: `opencreds`, `marksyncr`. A namespace MUST match +`^[a-z][a-z0-9-]{1,31}$`. + +### 4.7 Profiles + +A profile is how the user key is managed. The item envelope (§4.4) is identical +in both. + +**`user`** — the user key is wrapped by a key derived from a master password, as +in §4.3. One person, one password, one vault. + +**`team`** — the user key (there called the vault key) is generated randomly and +wrapped to each member with an anonymous sealed box against that member's X25519 +public key. The server stores one wrapped key per member and never sees the key +itself. Granting access is an existing member unwrapping and re-sealing to the +new member's public key. This is the scheme `logicsrc credentials` already +implements; see [credential-sharing.md](../credential-sharing.md). + +A vault MUST declare exactly one profile. An implementation MAY support one +profile and remain conforming; it MUST refuse a vault whose profile it does not +implement rather than attempting to open it. + +## 5. The portable database + +### 5.1 Shape + +A database is a single JSON document. It exists in two forms, distinguished by +`protected`. + +**Encrypted (default):** + +```json +{ + "opencreds": "0.1", + "type": "opencreds.database", + "protected": true, + "namespace": "opencreds", + "exportedAt": "2026-08-29T18:00:00.000Z", + "generator": { "name": "@logicsrc/opencreds", "version": "0.1.0" }, + "kdf": { "kdf": "pbkdf2-sha256", "iterations": 600000, "salt": "" }, + "manifest": { "itemCount": 42, "types": { "login": 40, "card": 2 }, + "folderCount": 3, "digest": "" }, + "iv": "", + "ciphertext": "" +} +``` + +**Plaintext:** + +```json +{ + "opencreds": "0.1", + "type": "opencreds.database", + "protected": false, + "namespace": "opencreds", + "exportedAt": "…", + "generator": { … }, + "manifest": { … }, + "folders": [ { "id": "…", "name": "Work" } ], + "items": [ { "v": 1, "id": "…", "type": "login", … } ] +} +``` + +### 5.2 Export key + +An export is encrypted under its own key, not the vault's user key. A database +that reused the user key would be undecryptable anywhere except the vault it +came from, which defeats the point of the file. + +``` +export passphrase ─PBKDF2(export salt, iterations)─► HKDF(":database:v1") ─► export key +``` + +The export salt is fresh per export and carried in the file. An implementation +MAY instead accept a raw 32-byte key, in which case `kdf` is omitted. + +The payload encrypted is the UTF-8 JSON of `{ "folders": [...], "items": [...] }`, +under AES-256-GCM with a fresh IV and: + +``` +AAD = UTF8(JSON of the header: opencreds, type, protected, namespace, + exportedAt, generator, kdf, manifest — keys in that order) +``` + +Binding the header means the manifest is authenticated by the same tag as the +data. An attacker cannot restate the item count, swap the generator, or downgrade +`protected` without the decryption failing. + +### 5.3 Manifest + +| Field | Meaning | +| --- | --- | +| `itemCount` | Number of items in the payload. | +| `types` | Item count per type name. Types with zero items are omitted. | +| `folderCount` | Number of folders in the payload. | +| `digest` | Base64 SHA-256 over the item ids, sorted lexicographically and joined by `\n`. | + +After decrypting, an implementation MUST recompute all four and MUST refuse the +import if any disagrees. A truncated file, a dropped item and a re-ordered +payload are all detectable; without the manifest a partial import is +indistinguishable from a complete one. + +For a plaintext database the manifest is still REQUIRED and MUST still be +verified. It is not authenticated — nothing in a plaintext file is — but it +still catches truncation and accidental editing. + +### 5.4 The plaintext form + +A plaintext database is every secret in a vault, in a file, in the clear. It +exists because people move to products that read nothing else. + +An implementation: + +- MUST default to the encrypted form. +- MUST require an explicit opt-in for the plaintext form, and SHOULD require a + second confirmation. +- MUST write `"protected": false` in the header, so the file is identifiable as + unprotected without parsing the rest of it. +- SHOULD write the file with owner-only permissions where the platform has them. +- SHOULD warn that the file cannot be un-leaked. + +### 5.5 Extension and media type + +The conventional extension is `.opencreds`. The media type is +`application/vnd.logicsrc.opencreds+json`. + +## 6. Audit + +An implementation that records vault operations SHOULD emit events of the form: + +```json +{ "type": "opencreds.audit_event", "id": "…", "action": "item.create", + "itemId": "…", "itemType": "login", "principal": { … }, + "fingerprint": "", "createdAt": "…" } +``` + +An audit event MUST NOT contain a secret value. Where a value must be referenced, +it is referenced by salted fingerprint — an equality marker, not secret storage. + +Registered actions: `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`. + +## 7. Conformance + +See [conformance.md](./conformance.md) for the requirement checklist and the +fixture suite. In summary, a conforming implementation: + +1. Reads and writes items per §3 without dropping unknown fields. +2. Implements the envelope and key hierarchy of §4 for at least one profile. +3. Reads and writes both database forms per §5, verifying the manifest. +4. Refuses KDF parameters below the floor, unknown profiles, and unknown + namespaces. +5. Passes the published fixtures. + +## 8. Schemas + +Published in `@logicsrc/schemas`: + +| Schema | File | +| --- | --- | +| Item | `logicsrc-opencreds-item.schema.json` | +| Vault meta | `logicsrc-opencreds-vault-meta.schema.json` | +| Item envelope | `logicsrc-opencreds-envelope.schema.json` | +| Database | `logicsrc-opencreds-database.schema.json` | +| Manifest | `logicsrc-opencreds-manifest.schema.json` | +| Audit event | `logicsrc-opencreds-audit-event.schema.json` | + +## 9. Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-08-29 | Initial draft. Six item types, one envelope, two profiles, one database format. | diff --git a/package-lock.json b/package-lock.json index 977123c..8e8a2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2061,6 +2061,10 @@ "resolved": "packages/opencontext", "link": true }, + "node_modules/@logicsrc/opencreds": { + "resolved": "packages/opencreds", + "link": true + }, "node_modules/@logicsrc/openontology": { "resolved": "packages/openontology", "link": true @@ -7767,6 +7771,7 @@ "dependencies": { "@logicsrc/account-core": "file:../account-core", "@logicsrc/opencontext": "file:../opencontext", + "@logicsrc/opencreds": "file:../opencreds", "@logicsrc/openontology": "file:../openontology", "@logicsrc/openprd": "file:../openprd", "@logicsrc/plugin-agentbbs": "file:../../plugins/agentbbs", @@ -7823,6 +7828,20 @@ "vitest": "^4.0.8" } }, + "packages/opencreds": { + "name": "@logicsrc/opencreds", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^14.0.2" + }, + "bin": { + "opencreds": "dist/cli.js" + }, + "devDependencies": { + "vitest": "^4.0.8" + } + }, "packages/openontology": { "name": "@logicsrc/openontology", "version": "0.1.0", diff --git a/package.json b/package.json index 6417c15..db5b0ac 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,14 @@ "apps/*" ], "scripts": { - "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", + "build": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/sdk run build && npm --workspace @logicsrc/agentad run build && npm --workspace @logicsrc/ans run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/agentstack run build && npm --workspace @logicsrc/agentswarm run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-sh1pt run build && npm --workspace @logicsrc/plugin-c0mpute run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-agentgit run build && npm --workspace @logicsrc/plugin-agentmail run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build && npm --workspace @profullstack/logicsrc-mcp run build && npm --workspace @logicsrc/commandboard-api run build && npm --workspace @logicsrc/commandboard-web run build && npm --workspace @logicsrc/web run build", "start": "npm --workspace @logicsrc/web run start", "test": "npm run test --workspaces --if-present", "check": "npm run build && npm run test", "schemas:validate": "npm --workspace @logicsrc/validators run validate:fixtures", "test:contract": "npm --workspace @logicsrc/commandboard-api run test:contract && npm --workspace @logicsrc/web run test:contract", "test:e2e": "npm --workspace @logicsrc/commandboard-web run test:e2e && npm --workspace @logicsrc/web run test:e2e", - "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build" + "build:cli": "npm --workspace @logicsrc/schemas run build && npm --workspace @logicsrc/validators run build && npm --workspace @logicsrc/plugin-core run build && npm --workspace @logicsrc/account-core run build && npm --workspace @logicsrc/plugin-coinpay run build && npm --workspace @logicsrc/plugin-ugig run build && npm --workspace @logicsrc/plugin-feed-discovery run build && npm --workspace @logicsrc/plugin-social-accounts run build && npm --workspace @logicsrc/plugin-email-accounts run build && npm --workspace @logicsrc/plugin-agentbbs run build && npm --workspace @logicsrc/plugin-credential-sharing run build && npm --workspace @logicsrc/openontology run build && npm --workspace @logicsrc/openprd run build && npm --workspace @logicsrc/opencontext run build && npm --workspace @logicsrc/opencreds run build && npm --workspace @logicsrc/tui run build && npm --workspace @logicsrc/cli run build" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4ff782c..2cf1cfc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,7 @@ "dependencies": { "@logicsrc/account-core": "file:../account-core", "@logicsrc/opencontext": "file:../opencontext", + "@logicsrc/opencreds": "file:../opencreds", "@logicsrc/openontology": "file:../openontology", "@logicsrc/openprd": "file:../openprd", "@logicsrc/plugin-agentbbs": "file:../../plugins/agentbbs", diff --git a/packages/cli/src/creds.ts b/packages/cli/src/creds.ts new file mode 100644 index 0000000..a558cc6 --- /dev/null +++ b/packages/cli/src/creds.ts @@ -0,0 +1,30 @@ +import type { Command } from "commander"; +import { registerCredsCommands } from "@logicsrc/opencreds/commands"; + +/** + * `logicsrc vault …` + * + * The commands themselves live in `@logicsrc/opencreds` and are shared verbatim + * with the standalone `opencreds` binary, so the two can never drift. That + * matters because the specification treats CLI behaviour — flags, output shapes + * and exit codes — as a conformance surface, and a subcommand that quietly + * diverged would make `logicsrc vault validate` and `opencreds validate` two + * different contracts. + * + * Named `vault` rather than `creds` because `creds` is already an alias of + * `logicsrc credentials`, and the two are genuinely different things: + * `credentials` moves a key/value pair *between providers*, while `vault` + * *stores a record* — a login, a card, an identity, a note, a key or an + * account — encrypted end to end and portable as one file rather than a + * plaintext CSV. They meet at the `key` item: a synced .env entry, stored. + */ +export function registerOpenCredsCommands(program: Command): void { + const vault = program + .command("vault") + .description( + "OpenCreds: an end-to-end-encrypted vault for logins, cards, identities, notes, keys " + + "and accounts, portable as one file. Also available as the standalone `opencreds` command.", + ); + + registerCredsCommands(vault); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 92d9eb3..fe33516 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -34,6 +34,7 @@ import { print, type OutputFormat } from "./format.js"; import { parsePositiveInteger } from "./numeric-options.js"; import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js"; import { registerOpenContextCommands } from "./context.js"; +import { registerOpenCredsCommands } from "./creds.js"; import { registerOntologyCommands } from "./ontology.js"; import { registerPrdCommands } from "./prd.js"; import { defaultPluginRegistry } from "./registry.js"; @@ -1037,6 +1038,7 @@ async function runYoloArcade(game: string, repo?: string) { } registerOpenContextCommands(program); +registerOpenCredsCommands(program); registerOntologyCommands(program); registerPrdCommands(program); diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts index f6c5ad6..50340e8 100644 --- a/packages/logicsrc-mcp/src/standards.test.ts +++ b/packages/logicsrc-mcp/src/standards.test.ts @@ -203,7 +203,7 @@ describe("MCP: OpenPRD", () => { it("reports the next free id and the allowed lifecycle moves", async () => { const client = await connect(); // Asserted against the live prd/ directory, so this advances with every PRD added. - expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0004"); + expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0005"); const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } }); const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] }; diff --git a/packages/opencreds/package.json b/packages/opencreds/package.json new file mode 100644 index 0000000..9f65adf --- /dev/null +++ b/packages/opencreds/package.json @@ -0,0 +1,51 @@ +{ + "name": "@logicsrc/opencreds", + "version": "0.1.0", + "description": "Reference implementation of the LogicSRC OpenCreds standard: 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.", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./commands": "./dist/commands.js", + "./importers": "./dist/importers.js" + }, + "bin": { + "opencreds": "./dist/cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/logicsrc.git", + "directory": "packages/opencreds" + }, + "homepage": "https://logicsrc.com/opencreds", + "keywords": [ + "logicsrc", + "opencreds", + "credentials", + "password-manager", + "vault", + "encryption", + "end-to-end-encryption", + "json-schema", + "standards" + ], + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "fixtures" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run src" + }, + "dependencies": { + "commander": "^14.0.2" + }, + "devDependencies": { + "vitest": "^4.0.8" + } +} diff --git a/packages/opencreds/src/audit.ts b/packages/opencreds/src/audit.ts new file mode 100644 index 0000000..f469068 --- /dev/null +++ b/packages/opencreds/src/audit.ts @@ -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 { + 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, + }; +} diff --git a/packages/opencreds/src/cli.test.ts b/packages/opencreds/src/cli.test.ts new file mode 100644 index 0000000..ba70741 --- /dev/null +++ b/packages/opencreds/src/cli.test.ts @@ -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 = {}): Promise { + 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 { + 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", "", "--mode", "0600"]), + await authed(["add", "account", "--name", "Stripe", "--provider", "stripe", "--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(""); + 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,,", + ",,,,,,,,", + "", + ].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); + }); +}); diff --git a/packages/opencreds/src/cli.ts b/packages/opencreds/src/cli.ts new file mode 100644 index 0000000..06ce158 --- /dev/null +++ b/packages/opencreds/src/cli.ts @@ -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; +}); diff --git a/packages/opencreds/src/commands.ts b/packages/opencreds/src/commands.ts new file mode 100644 index 0000000..0baf142 --- /dev/null +++ b/packages/opencreds/src/commands.ts @@ -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): Promise { + 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(); + return createVaultStore(opts.home ?? opencredsHome()); +} + +function requireMeta(store: ReturnType) { + 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): Promise { + 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, userKey: Uint8Array): Promise { + 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, + userKey: Uint8Array, + item: Item, +): Promise { + 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> = { + login: [ + ["--username ", "username"], + ["--password ", "password", true], + ["--totp ", "totp", true], + ], + card: [ + ["--cardholder-name ", "cardholderName"], + ["--brand ", "brand"], + ["--number ", "number", true], + ["--exp-month ", "expMonth"], + ["--exp-year ", "expYear"], + ["--code ", "code", true], + ], + identity: [ + ["--title ", "title"], + ["--first-name ", "firstName"], + ["--middle-name ", "middleName"], + ["--last-name ", "lastName"], + ["--company ", "company"], + ["--email ", "email"], + ["--phone ", "phone"], + ["--address1 ", "address1"], + ["--address2 ", "address2"], + ["--city ", "city"], + ["--state ", "state"], + ["--postal-code ", "postalCode"], + ["--country ", "country"], + ["--ssn ", "ssn", true], + ["--passport-number ", "passportNumber", true], + ["--license-number ", "licenseNumber", true], + ], + note: [], + key: [ + ["--key-type ", "keyType"], + ["--algorithm ", "algorithm"], + ["--public-key ", "publicKey"], + ["--private-key ", "privateKey", true], + ["--passphrase ", "passphrase", true], + ["--fingerprint ", "fingerprint"], + ["--value ", "value", true], + ["--path ", "path"], + ["--mode ", "mode"], + ], + account: [ + ["--provider ", "provider"], + ["--account-id ", "accountId"], + ["--handle ", "handle"], + ["--email ", "email"], + ["--access-token ", "accessToken", true], + ["--refresh-token ", "refreshToken", true], + ["--token-type ", "tokenType"], + ["--environment ", "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): Promise> { + const group: Record = {}; + 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 ", "matching URI"); + command.option("--match ", "URI match rule", "domain"); + } + if (type === "account") { + command.option("--scope ", "OAuth scope (repeatable)"); + } + if (type === "key") { + command.option("--file ", "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 ", "vault directory (default $OPENCREDS_HOME)"); + + // ---------------------------------------------------------------- vault --- + + parent + .command("init") + .description("create a vault") + .option("--namespace ", "domain-separation namespace", "opencreds") + .option("--iterations ", "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 ", "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> = {}; + 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 ", "display name") + .option("--notes ", "notes") + .option("--folder ", "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) { + 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); + + 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 ", "filter by item type") + .option("--folder ", "filter by folder") + .option("--search ", "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("", "item id or name") + .description("show one item, with every secret masked") + .option("--field ", "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("", "item id or name") + .description(`edit a ${type}`) + .option("--name ", "display name") + .option("--notes ", "notes") + .option("--favorite ", "true or false"); + applyTypeFlags(sub, type); + sub.action(async function (this: Command, needle: string, opts: Record) { + 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 = {}; + 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); + + 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("", "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("", "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 ", "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 ", "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("", "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 ", "skip, replace or duplicate", "skip") + .option("--source ", `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> = {}; + 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 ", "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("", "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`); + }); + }); +} diff --git a/packages/opencreds/src/conformance.test.ts b/packages/opencreds/src/conformance.test.ts new file mode 100644 index 0000000..f9021da --- /dev/null +++ b/packages/opencreds/src/conformance.test.ts @@ -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); +}); diff --git a/packages/opencreds/src/conformance.ts b/packages/opencreds/src/conformance.ts new file mode 100644 index 0000000..84ea4eb --- /dev/null +++ b/packages/opencreds/src/conformance.ts @@ -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; +} + +/** 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, what: string): Promise { + 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), + createItem("card", { + name: "Visa", + card: { cardholderName: "A Ettinger", brand: "Visa", number: "4242424242424242", expMonth: "4", expYear: "2029", code: "123" }, + } as Partial), + createItem("identity", { + name: "Me", + identity: { firstName: "Anthony", lastName: "Ettinger", ssn: "000-00-0000" }, + } as Partial), + 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: "", path: "~/.ssh/id_ed25519", mode: "0600" }, + } as Partial), + createItem("account", { + name: "Stripe", + account: { provider: "stripe", accessToken: "", scopes: ["charges:write", "customers:read"], environment: "production" }, + } as Partial), + ], + }; +} + +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)[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); + 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); + 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(); + 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); + 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 { + 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> { + 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), + "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"); +} diff --git a/packages/opencreds/src/crypto.test.ts b/packages/opencreds/src/crypto.test.ts new file mode 100644 index 0000000..0f6dc94 --- /dev/null +++ b/packages/opencreds/src/crypto.test.ts @@ -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(); + 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/); + }); +}); diff --git a/packages/opencreds/src/database.test.ts b/packages/opencreds/src/database.test.ts new file mode 100644 index 0000000..869e57b --- /dev/null +++ b/packages/opencreds/src/database.test.ts @@ -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), + createItem("key", { name: "deploy", key: { keyType: "ssh", path: "~/.ssh/id_ed25519", mode: "0600" } } as Partial), + createItem("account", { name: "Stripe", account: { provider: "stripe", scopes: ["charges:write"] } } as Partial), + createItem("note", { name: "wifi", notes: "the password is on the router" }), + createItem("identity", { name: "me", identity: { firstName: "A", lastName: "E" } } as Partial), + ], + }; +} + +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/); + }); +}); diff --git a/packages/opencreds/src/database.ts b/packages/opencreds/src/database.ts new file mode 100644 index 0000000..7862479 --- /dev/null +++ b/packages/opencreds/src/database.ts @@ -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 { + const types: Partial> = {}; + 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 { + 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 = { + 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 { + 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 { + 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 { + 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(); + 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; +} diff --git a/packages/opencreds/src/importers.test.ts b/packages/opencreds/src/importers.test.ts new file mode 100644 index 0000000..5418d6f --- /dev/null +++ b/packages/opencreds/src/importers.test.ts @@ -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), + 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"'); + }); +}); diff --git a/packages/opencreds/src/importers.ts b/packages/opencreds/src/importers.ts new file mode 100644 index 0000000..159788d --- /dev/null +++ b/packages/opencreds/src/importers.ts @@ -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> { + if (rows.length < 2) return []; + const headers = rows[0]!.map((h) => h.trim().toLowerCase()); + return rows.slice(1).map((row) => { + const obj: Record = {}; + headers.forEach((header, i) => { + obj[header] = row[i] ?? ""; + }); + return obj; + }); +} + +/** First non-empty value among the given column names. */ +function firstOf(row: Record, ...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(); + 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, folders: ReturnType) => 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, folders: ReturnType): 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); + } + 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); + } + if (type === "note" || type === "securenote") { + return createItem("note", common as Partial); + } + + 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); +} + +/** 1Password: title, url, username, password, otpauth, notes, type */ +function mapOnePasswordRow(row: Record, folders: ReturnType): 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); +} + +/** Chrome: name, url, username, password, note */ +function mapChromeRow(row: Record): 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); +} + +/** + * 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, folders: ReturnType): 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); + } + 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); +} + +/** KeePass CSV: "Account","Login Name","Password","Web Site","Comments","Group" */ +function mapKeePassRow(row: Record, folders: ReturnType): 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); +} + +/** + * 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> = 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 } { + const folderName = new Map(folders.map((f) => [f.id, f.name])); + const dropped: Record = {}; + 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 }; +} diff --git a/packages/opencreds/src/index.ts b/packages/opencreds/src/index.ts new file mode 100644 index 0000000..6d1f381 --- /dev/null +++ b/packages/opencreds/src/index.ts @@ -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"; diff --git a/packages/opencreds/src/items.test.ts b/packages/opencreds/src/items.test.ts new file mode 100644 index 0000000..bee0efd --- /dev/null +++ b/packages/opencreds/src/items.test.ts @@ -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); + 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); + 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); + + 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); + 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); + 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); + 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(); + }); +}); diff --git a/packages/opencreds/src/items.ts b/packages/opencreds/src/items.ts new file mode 100644 index 0000000..53e4599 --- /dev/null +++ b/packages/opencreds/src/items.ts @@ -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 => ({}), + 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 { + 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)[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)[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): Record { + const out: Record = {}; + 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)[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 { + const group = (patch as Record)[item.type]; + const next: Item = { + ...item, + ...stripGroups(patch), + updatedAt: new Date().toISOString(), + }; + if (group && typeof group === "object" && item.type !== "note") { + (next as Record)[item.type] = { + ...((item as Record)[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 { + 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 { + 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 { + 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> = 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)[group] as Record | 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)[part]; + } + return typeof cursor === "string" ? cursor : cursor === undefined ? undefined : JSON.stringify(cursor); +} diff --git a/packages/opencreds/src/kdf.ts b/packages/opencreds/src/kdf.ts new file mode 100644 index 0000000..730688f --- /dev/null +++ b/packages/opencreds/src/kdf.ts @@ -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 = 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 | 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/opencreds/src/primitives.ts b/packages/opencreds/src/primitives.ts new file mode 100644 index 0000000..64752d3 --- /dev/null +++ b/packages/opencreds/src/primitives.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/packages/opencreds/src/prompt.ts b/packages/opencreds/src/prompt.ts new file mode 100644 index 0000000..e868116 --- /dev/null +++ b/packages/opencreds/src/prompt.ts @@ -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 { + 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((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 { + 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 { + const rl = createInterface({ input: stdin, output: stdout }); + const answer = await new Promise((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 { + if (!stdin.isTTY) return false; + const answer = await promptLine(`${question} [y/N] `); + return /^y(es)?$/i.test(answer.trim()); +} + +export function readLineFromStdin(): Promise { + 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 { + if (value === undefined) return undefined; + if (value === "-") return readLineFromStdin(); + return value; +} diff --git a/packages/opencreds/src/session.ts b/packages/opencreds/src/session.ts new file mode 100644 index 0000000..e7020db --- /dev/null +++ b/packages/opencreds/src/session.ts @@ -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; + } +} diff --git a/packages/opencreds/src/store.ts b/packages/opencreds/src/store.ts new file mode 100644 index 0000000..60ebf9a --- /dev/null +++ b/packages/opencreds/src/store.ts @@ -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/.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(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(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(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(join(itemsDir, `${id}.json`)); + }, + + writeEnvelope(envelope: Envelope): void { + ensureDirs(); + const existing = readJson(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(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 []; + } + }); + }, + }; +} diff --git a/packages/opencreds/src/types.ts b/packages/opencreds/src/types.ts new file mode 100644 index 0000000..17301ed --- /dev/null +++ b/packages/opencreds/src/types.ts @@ -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> = Object.freeze( + Object.fromEntries(Object.entries(ITEM_TYPE).map(([name, id]) => [id, name])) as Record, +); + +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>; + 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; +} diff --git a/packages/opencreds/src/validate.test.ts b/packages/opencreds/src/validate.test.ts new file mode 100644 index 0000000..f0bc903 --- /dev/null +++ b/packages/opencreds/src/validate.test.ts @@ -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): 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); + 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); + 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))).toEqual([]); + } + }); + + it("checks custom field shapes", () => { + const item = createItem("note", { fields: [{ name: "a", value: "b", type: "mystery" }] } as unknown as Partial); + 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(""); + }); +}); diff --git a/packages/opencreds/src/validate.ts b/packages/opencreds/src/validate.ts new file mode 100644 index 0000000..944f4da --- /dev/null +++ b/packages/opencreds/src/validate.ts @@ -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 { + 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; +} diff --git a/packages/opencreds/src/vault-key.ts b/packages/opencreds/src/vault-key.ts new file mode 100644 index 0000000..efe47c8 --- /dev/null +++ b/packages/opencreds/src/vault-key.ts @@ -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 { + 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 { + 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 { + 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 { + 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), + }; +} diff --git a/packages/opencreds/tsconfig.json b/packages/opencreds/tsconfig.json new file mode 100644 index 0000000..c6bc9db --- /dev/null +++ b/packages/opencreds/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-helpers.ts"] +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 461ac51..a1e33d1 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,7 +1,7 @@ { "name": "@logicsrc/schemas", "version": "0.1.0", - "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, and the OpenContext context plane.", + "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, and the OpenCreds credential vault.", "license": "MIT", "type": "module", "repository": { @@ -21,8 +21,11 @@ "logicsrc", "ontology", "opencontext", + "opencreds", "openontology", - "standards" + "password-manager", + "standards", + "vault" ], "publishConfig": { "access": "public" @@ -55,6 +58,12 @@ "./opencontext-object": "./schemas/logicsrc-opencontext-object.schema.json", "./opencontext-provenance": "./schemas/logicsrc-opencontext-provenance.schema.json", "./opencontext-role": "./schemas/logicsrc-opencontext-role.schema.json", + "./opencreds-audit-event": "./schemas/logicsrc-opencreds-audit-event.schema.json", + "./opencreds-database": "./schemas/logicsrc-opencreds-database.schema.json", + "./opencreds-envelope": "./schemas/logicsrc-opencreds-envelope.schema.json", + "./opencreds-item": "./schemas/logicsrc-opencreds-item.schema.json", + "./opencreds-manifest": "./schemas/logicsrc-opencreds-manifest.schema.json", + "./opencreds-vault-meta": "./schemas/logicsrc-opencreds-vault-meta.schema.json", "./openontology-action": "./schemas/logicsrc-openontology-action.schema.json", "./openontology-approval": "./schemas/logicsrc-openontology-approval.schema.json", "./openontology-changeset": "./schemas/logicsrc-openontology-changeset.schema.json", diff --git a/packages/schemas/schemas/logicsrc-opencreds-audit-event.schema.json b/packages/schemas/schemas/logicsrc-opencreds-audit-event.schema.json new file mode 100644 index 0000000..ecd9db5 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-audit-event.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/audit-event.schema.json", + "title": "OpenCreds Audit Event", + "description": "One recorded vault operation. An audit event MUST NOT contain a secret value. Where a value must be referenced it is referenced by salted, truncated SHA-256 fingerprint — an equality and integrity marker, not secret storage. Item names are also secret-adjacent (a folder list is a good description of someone's life), so an event carries the item id and type rather than its name.", + "type": "object", + "required": ["type", "id", "action", "createdAt"], + "additionalProperties": false, + "properties": { + "type": { "type": "string", "const": "opencreds.audit_event" }, + "id": { "type": "string" }, + "action": { + "type": "string", + "enum": [ + "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" + ] + }, + "itemId": { "type": "string" }, + "itemType": { "type": "string", "enum": ["login", "card", "identity", "note", "key", "account"] }, + "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "profile": { "type": "string", "enum": ["user", "team"] }, + "principal": { + "type": "object", + "description": "Who acted. Shape borrowed from LogicSRC account-core so an OpenCreds event sits alongside the credential-sharing audit trail.", + "additionalProperties": true, + "properties": { + "kind": { "type": "string", "enum": ["user", "agent", "service"] }, + "id": { "type": "string" }, + "label": { "type": "string" } + } + }, + "fingerprint": { + "type": "string", + "description": "Salted, truncated SHA-256 of the value observed or written. Never the value." + }, + "itemCount": { "type": "integer", "minimum": 0, "description": "For database.* actions." }, + "dryRun": { "type": "boolean", "default": false }, + "outcome": { "type": "string", "enum": ["succeeded", "failed", "refused"] }, + "reason": { "type": "string", "description": "Why a failed or refused action did not proceed. Never contains a value." }, + "createdAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/schemas/schemas/logicsrc-opencreds-database.schema.json b/packages/schemas/schemas/logicsrc-opencreds-database.schema.json new file mode 100644 index 0000000..7f00d3e --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-database.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/database.schema.json", + "title": "OpenCreds Portable Database", + "description": "A vault as one file: what you hand to another product, keep as a backup, and read to import. Encrypted by default. The plaintext form 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 — no warning, no file mode, no label. So it is specified and made loud: an explicit opt-in, and protected:false in the header so tooling can identify the file without parsing the rest of it. Media type application/vnd.logicsrc.opencreds+json; conventional extension .opencreds.", + "type": "object", + "required": ["opencreds", "type", "protected", "namespace", "exportedAt", "manifest"], + "additionalProperties": false, + "properties": { + "opencreds": { "type": "string", "const": "0.1" }, + "type": { "type": "string", "const": "opencreds.database" }, + "protected": { "type": "boolean" }, + "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" }, + "exportedAt": { "type": "string", "format": "date-time" }, + "generator": { + "type": "object", + "required": ["name", "version"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + } + }, + "kdf": { + "type": "object", + "required": ["kdf", "iterations", "salt"], + "additionalProperties": false, + "description": "Parameters for the export key, which is derived from an export passphrase and is NOT 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. Omitted when a raw 32-byte key was supplied instead.", + "properties": { + "kdf": { "type": "string", "enum": ["pbkdf2-sha256", "argon2id"] }, + "iterations": { "type": "integer", "minimum": 100000 }, + "salt": { "type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$" } + } + }, + "manifest": { "$ref": "https://logicsrc.com/schemas/opencreds/manifest.schema.json" }, + "iv": { "type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$" }, + "ciphertext": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "description": "AES-256-GCM over the UTF-8 JSON of {folders, items}, with the header (opencreds, type, protected, namespace, exportedAt, generator, kdf, manifest, in that key order) bound as additional authenticated data. An attacker cannot restate the item count, swap the generator, or downgrade protected without the decryption failing." + }, + "folders": { + "type": "array", + "items": { "$ref": "#/$defs/folder" }, + "description": "Plaintext form only." + }, + "items": { + "type": "array", + "items": { "$ref": "https://logicsrc.com/schemas/opencreds/item.schema.json" }, + "description": "Plaintext form only. Every secret in the vault, in the clear." + } + }, + "allOf": [ + { + "$comment": "The two forms are mutually exclusive: an encrypted database carries a payload it cannot also state in the clear. A false subschema is how JSON Schema says 'this property must be absent'.", + "if": { "properties": { "protected": { "const": true } }, "required": ["protected"] }, + "then": { + "required": ["iv", "ciphertext"], + "properties": { + "iv": { "type": "string" }, + "ciphertext": { "type": "string" }, + "items": false, + "folders": false + } + } + }, + { + "if": { "properties": { "protected": { "const": false } }, "required": ["protected"] }, + "then": { + "required": ["items"], + "properties": { + "items": { "type": "array" }, + "iv": false, + "ciphertext": false, + "kdf": false + } + } + } + ], + "$defs": { + "folder": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[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}$" + }, + "name": { "type": "string" } + } + } + } +} diff --git a/packages/schemas/schemas/logicsrc-opencreds-envelope.schema.json b/packages/schemas/schemas/logicsrc-opencreds-envelope.schema.json new file mode 100644 index 0000000..32bfef1 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-envelope.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/envelope.schema.json", + "title": "OpenCreds Item Envelope", + "description": "One encrypted item as it is stored. AES-256-GCM over the item's UTF-8 JSON, with UTF8(\":vault:item::\") bound as additional authenticated data. Binding the id means a ciphertext cannot be moved between rows: without it, anyone with write access to the storage could copy a low-value login's ciphertext into a high-value one's row and watch what the user does next. Only id and type are plaintext, and that is the metadata this design accepts leaking — the server learns you hold forty logins and two cards, never which sites or what values.", + "type": "object", + "required": ["id", "type", "ciphertext", "iv"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[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}$", + "description": "Matches the id inside the ciphertext. A decrypting implementation MUST verify the two agree; the AAD already makes a mismatch unreachable, and the check costs nothing." + }, + "type": { + "type": "integer", + "minimum": 1, + "maximum": 6, + "description": "1 login, 2 card, 3 identity, 4 note, 5 key, 6 account. Plaintext on purpose, so a server can filter and paginate without decrypting." + }, + "ciphertext": { "type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$" }, + "iv": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "description": "96 bits, fresh per encryption. With GCM a repeated IV under one key is not a weakness but a break, leaking the XOR of two plaintexts along with the authentication subkey." + }, + "v": { "type": "integer", "minimum": 1, "description": "Item schema version, when the storage carries it outside the ciphertext. Defaults to 1." }, + "revision": { + "type": "integer", + "minimum": 1, + "description": "Optimistic concurrency. Two devices editing two different passwords at the same moment must not cost anyone a credential; a client that writes with a stale revision is rejected rather than overwriting." + }, + "deletedAt": { "type": ["string", "null"], "description": "Trash bin, not erasure." }, + "purgeAfter": { "type": ["string", "null"] }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/packages/schemas/schemas/logicsrc-opencreds-item.schema.json b/packages/schemas/schemas/logicsrc-opencreds-item.schema.json new file mode 100644 index 0000000..1b17572 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-item.schema.json @@ -0,0 +1,240 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/item.schema.json", + "title": "OpenCreds Item", + "description": "One credential record: a login, a card, an identity, a note, a key, or an account. These are not six features but one record with a type and a named field group, so that everything the user typed lives inside a single encrypted blob. That is what makes password history free — it is an array in that blob, encrypted by construction, rather than a second table someone can forget to protect. Every field in every group is optional and defaults to an empty string, because a real vault holds half-filled records and a model that requires fields produces importers that invent them.", + "type": "object", + "required": ["v", "id", "type", "name", "createdAt", "updatedAt"], + "additionalProperties": true, + "properties": { + "v": { + "type": "integer", + "minimum": 1, + "description": "Item schema version, stamped into every record from the first write. A record you cannot identify is a record you cannot migrate. 1 for OpenCreds 0.1." + }, + "id": { + "$ref": "#/$defs/uuid", + "description": "Generated on the client, because it is bound into the ciphertext as additional authenticated data. A ciphertext moved to another row fails to decrypt rather than silently showing the wrong credential." + }, + "type": { + "$ref": "#/$defs/itemType", + "description": "Selects the field group. An item MUST NOT carry a group other than the one named here." + }, + "name": { + "type": "string", + "description": "Display name. MAY be empty; importers fall back to the URL host when an export had none." + }, + "favorite": { "type": "boolean", "default": false }, + "folderId": { + "oneOf": [{ "$ref": "#/$defs/uuid" }, { "type": "null" }], + "description": "A folder declared in the same vault, or null. Folders are flat: a name may contain a slash and a client may render that as a hierarchy, but the model does not nest, because every product that nests disagrees about what a move does." + }, + "notes": { + "type": "string", + "description": "Free text. For a note item this is the content." + }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/customField" }, + "description": "Custom fields. A hidden field is displayed masked; it is not encrypted differently, because everything in the item is already inside one ciphertext." + }, + "attachments": { + "type": "array", + "items": { "$ref": "#/$defs/attachment" }, + "description": "Attachment references, never bytes. An implementation that does not store blobs MUST still round-trip these." + }, + "history": { + "type": "array", + "maxItems": 20, + "items": { "$ref": "#/$defs/historyEntry" }, + "description": "Password history, newest first, defined only for login items. Capped at 20: the item blob is rewritten on every save, so an uncapped array grows the ciphertext without bound and the growth is invisible until sync starts timing out." + }, + "createdAt": { "$ref": "#/$defs/timestamp" }, + "updatedAt": { + "$ref": "#/$defs/timestamp", + "description": "Belongs to the item, not to a transfer. An importer that restamps this destroys the only evidence of when a password was last rotated." + }, + "login": { "$ref": "#/$defs/loginGroup" }, + "card": { "$ref": "#/$defs/cardGroup" }, + "identity": { "$ref": "#/$defs/identityGroup" }, + "key": { "$ref": "#/$defs/keyGroup" }, + "account": { "$ref": "#/$defs/accountGroup" } + }, + "allOf": [ + { + "$comment": "An item MUST NOT carry a field group other than the one named by its type. 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. A false subschema is how JSON Schema says 'this property must be absent'.", + "if": { "properties": { "type": { "const": "login" } }, "required": ["type"] }, + "then": { "properties": { "card": false, "identity": false, "key": false, "account": false } } + }, + { + "if": { "properties": { "type": { "const": "card" } }, "required": ["type"] }, + "then": { "properties": { "login": false, "identity": false, "key": false, "account": false } } + }, + { + "if": { "properties": { "type": { "const": "identity" } }, "required": ["type"] }, + "then": { "properties": { "login": false, "card": false, "key": false, "account": false } } + }, + { + "if": { "properties": { "type": { "const": "note" } }, "required": ["type"] }, + "then": { "properties": { "login": false, "card": false, "identity": false, "key": false, "account": false } } + }, + { + "if": { "properties": { "type": { "const": "key" } }, "required": ["type"] }, + "then": { "properties": { "login": false, "card": false, "identity": false, "account": false } } + }, + { + "if": { "properties": { "type": { "const": "account" } }, "required": ["type"] }, + "then": { "properties": { "login": false, "card": false, "identity": false, "key": false } } + }, + { + "$comment": "Password history is defined only for logins.", + "if": { "not": { "properties": { "type": { "const": "login" } }, "required": ["type"] } }, + "then": { "properties": { "history": { "type": "array", "maxItems": 0 } } } + } + ], + "$defs": { + "uuid": { + "type": "string", + "pattern": "^[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}$" + }, + "timestamp": { "type": "string", "format": "date-time" }, + "itemType": { + "type": "string", + "enum": ["login", "card", "identity", "note", "key", "account"], + "description": "Type codes 1 login, 2 card, 3 identity, 4 note, 5 key, 6 account. Codes 1-4 are fixed by a deployed vault and MUST NOT be renumbered; 5 and 6 are introduced by OpenCreds; 7+ are reserved." + }, + "customField": { + "type": "object", + "required": ["name", "value", "type"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" }, + "type": { "type": "string", "enum": ["text", "hidden", "boolean", "linked"] }, + "hidden": { "type": "boolean", "default": false } + } + }, + "attachment": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "size": { "type": "integer", "minimum": 0 }, + "contentType": { "type": "string" }, + "digest": { "type": "string", "description": "sha256- over the plaintext blob." }, + "key": { + "type": "string", + "description": "Base64 AES-256 key the blob was encrypted under, held inside the item ciphertext so the blob store never sees it." + } + } + }, + "historyEntry": { + "type": "object", + "required": ["password", "changedAt"], + "additionalProperties": false, + "properties": { + "password": { "type": "string" }, + "changedAt": { "$ref": "#/$defs/timestamp" } + } + }, + "uri": { + "type": "object", + "required": ["uri"], + "additionalProperties": false, + "properties": { + "uri": { "type": "string" }, + "match": { + "type": "string", + "enum": ["domain", "host", "startsWith", "exact", "regex", "never"], + "default": "domain", + "description": "Carried so that a move does not silently widen where a credential will be offered. An implementation that does not autofill still round-trips it." + } + } + }, + "loginGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" }, + "totp": { + "type": "string", + "description": "An otpauth:// URI, or a bare base32 seed. Store the URI where you have it: it carries the algorithm, digits and period, and a bare seed loses them." + }, + "uris": { "type": "array", "items": { "$ref": "#/$defs/uri" } } + } + }, + "cardGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "cardholderName": { "type": "string" }, + "brand": { "type": "string", "description": "Free text; issuers add brands." }, + "number": { "type": "string" }, + "expMonth": { "type": "string", "description": "1-12, no leading zero required." }, + "expYear": { "type": "string", "description": "Four digits. Two-digit years from an import are expanded to 20xx." }, + "code": { "type": "string" } + } + }, + "identityGroup": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "type": "string" }, + "firstName": { "type": "string" }, + "middleName": { "type": "string" }, + "lastName": { "type": "string" }, + "username": { "type": "string" }, + "company": { "type": "string" }, + "email": { "type": "string" }, + "phone": { "type": "string" }, + "address1": { "type": "string" }, + "address2": { "type": "string" }, + "address3": { "type": "string" }, + "city": { "type": "string" }, + "state": { "type": "string" }, + "postalCode": { "type": "string" }, + "country": { "type": "string" }, + "ssn": { "type": "string", "description": "National identity number. Named ssn for import compatibility; it is not US-specific." }, + "passportNumber": { "type": "string" }, + "licenseNumber": { "type": "string" } + } + }, + "keyGroup": { + "type": "object", + "additionalProperties": false, + "description": "SSH and PGP keys, API tokens, certificates, and the .env secrets that Credential Sharing synchronizes. path and mode exist so a restore is total: a private key written back with the wrong mode is a key ssh will refuse to use, and one written to the wrong path is a key nothing finds.", + "properties": { + "keyType": { "type": "string", "enum": ["ssh", "pgp", "api", "symmetric", "certificate", "env"] }, + "algorithm": { "type": "string" }, + "publicKey": { "type": "string" }, + "privateKey": { "type": "string" }, + "passphrase": { "type": "string" }, + "fingerprint": { "type": "string", "description": "SHA256:… — a public, non-secret identifier." }, + "value": { "type": "string", "description": "The secret for key types that are one opaque string (api, env, symmetric)." }, + "path": { "type": "string" }, + "mode": { "type": "string", "pattern": "^0?[0-7]{3}$" }, + "expiresAt": { "type": "string" } + } + }, + "accountGroup": { + "type": "object", + "additionalProperties": false, + "description": "A provider account and the tokens that authorize acting as it. Deliberately not a login: a login is what a person types at a sign-in form, an account is what a machine presents to an API. They expire differently and are revoked differently, and conflating them is how a rotated refresh token ends up in a password history array.", + "properties": { + "provider": { "type": "string", "description": "google, github, x, stripe, … An OpenOntology entity id where one is in use." }, + "accountId": { "type": "string" }, + "handle": { "type": "string" }, + "email": { "type": "string" }, + "accessToken": { "type": "string" }, + "refreshToken": { "type": "string" }, + "tokenType": { "type": "string" }, + "scopes": { "type": "array", "items": { "type": "string" } }, + "expiresAt": { "type": "string" }, + "environment": { "type": "string", "description": "production, sandbox, … A test key and a live key look identical and are not." } + } + } + } +} diff --git a/packages/schemas/schemas/logicsrc-opencreds-manifest.schema.json b/packages/schemas/schemas/logicsrc-opencreds-manifest.schema.json new file mode 100644 index 0000000..6fdb398 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-manifest.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/manifest.schema.json", + "title": "OpenCreds Database Manifest", + "description": "The authenticated inventory of a portable database. In an encrypted database the manifest sits in the header, which is bound as additional authenticated data over the payload — so the counts can be shown in a preview before anyone types a passphrase, and they cannot be lied about. After decrypting, an implementation MUST recompute all four fields and MUST refuse the import if any disagrees. This 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.", + "type": "object", + "required": ["itemCount", "types", "folderCount", "digest"], + "additionalProperties": false, + "properties": { + "itemCount": { "type": "integer", "minimum": 0 }, + "types": { + "type": "object", + "additionalProperties": false, + "description": "Item count per type name. Types with zero items are omitted.", + "properties": { + "login": { "type": "integer", "minimum": 1 }, + "card": { "type": "integer", "minimum": 1 }, + "identity": { "type": "integer", "minimum": 1 }, + "note": { "type": "integer", "minimum": 1 }, + "key": { "type": "integer", "minimum": 1 }, + "account": { "type": "integer", "minimum": 1 } + } + }, + "folderCount": { "type": "integer", "minimum": 0 }, + "digest": { + "type": "string", + "pattern": "^[A-Za-z0-9+/]*={0,2}$", + "description": "Base64 SHA-256 over the item ids, sorted lexicographically and joined by a newline. Detects a dropped item and a re-ordered payload alike." + } + } +} diff --git a/packages/schemas/schemas/logicsrc-opencreds-vault-meta.schema.json b/packages/schemas/schemas/logicsrc-opencreds-vault-meta.schema.json new file mode 100644 index 0000000..49e9c52 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-opencreds-vault-meta.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://logicsrc.com/schemas/opencreds/vault-meta.schema.json", + "title": "OpenCreds Vault Metadata", + "description": "Per-vault key material and parameters. Every value here is encrypted or derived on the client; nothing in this document can be decrypted with anything a server, its backups, or an operator with full access can see. Key material is base64 text rather than binary because this travels as JSON over HTTP, where binary round-trips as an escaped hex string and invites encoding mistakes on exactly the values that must not be corrupted.", + "type": "object", + "required": ["opencreds", "namespace", "profile", "kdf", "kdfIterations", "kdfSalt", "protectedUserKey", "protectedUserKeyIv"], + "additionalProperties": false, + "properties": { + "opencreds": { "type": "string", "const": "0.1" }, + "namespace": { + "$ref": "#/$defs/namespace", + "description": "Domain-separation label prefix. Carried as data because labels are compiled into the additional authenticated data of every ciphertext a vault has written: editing one does not migrate a vault, it makes it undecryptable. Registered: opencreds, marksyncr." + }, + "profile": { + "type": "string", + "enum": ["user", "team"], + "description": "How the user key is managed. user: wrapped by a key derived from a master password. team: sealed to each member's X25519 public key. The item envelope is identical under both. An implementation MUST refuse a profile it does not implement rather than attempting to open the vault." + }, + "kdf": { + "type": "string", + "enum": ["pbkdf2-sha256", "argon2id"], + "description": "argon2id is registered and not yet specified. A client that does not implement it MUST refuse the vault rather than fall back." + }, + "kdfIterations": { + "type": "integer", + "minimum": 100000, + "default": 600000, + "description": "Floor enforced in the client before deriving anything. These parameters arrive from a server, which makes them attacker-controlled if the server is compromised: serving 1 would turn every captured auth hash into an offline guessing exercise with no work factor." + }, + "kdfMemoryKib": { "type": "integer", "minimum": 1, "description": "Reserved for argon2id." }, + "kdfParallelism": { "type": "integer", "minimum": 1, "description": "Reserved for argon2id." }, + "kdfSalt": { "$ref": "#/$defs/base64", "description": "Per vault, random, at least 16 bytes." }, + "protectedUserKey": { + "$ref": "#/$defs/base64", + "description": "The 32-byte random user key, encrypted under the wrap key. Random rather than derived, so a master password change re-wraps 32 bytes instead of re-encrypting every item." + }, + "protectedUserKeyIv": { "$ref": "#/$defs/base64" }, + "recoveryKeyBlob": { + "$ref": "#/$defs/base64", + "description": "A second copy of the same user key under the recovery key, so a forgotten master password is survivable without the server learning anything." + }, + "recoveryKeyIv": { "$ref": "#/$defs/base64" }, + "authHash": { + "$ref": "#/$defs/base64", + "description": "The only password-derived value that may leave the device. It comes out of a different HKDF label than the wrap key, so holding every auth hash ever sent does not help derive a wrapping key. A server storing it MUST hash it again." + }, + "wrappedKeys": { + "type": "array", + "description": "team profile only: one sealed copy of the vault key per member. The server holds these and never the key.", + "items": { + "type": "object", + "required": ["memberId", "publicKey", "wrappedKey"], + "additionalProperties": false, + "properties": { + "memberId": { "type": "string" }, + "publicKey": { "$ref": "#/$defs/base64" }, + "wrappedKey": { "$ref": "#/$defs/base64" }, + "grantedAt": { "type": "string", "format": "date-time" } + } + } + }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "$defs": { + "base64": { "type": "string", "pattern": "^[A-Za-z0-9+/]*={0,2}$" }, + "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,31}$" } + } +} diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 07c60c9..76d811f 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -4,7 +4,15 @@ import type { ErrorObject } from "ajv"; import { parse } from "yaml"; import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js"; -const Ajv2020 = (Ajv2020Module as unknown as { default: new (options: Record) => { compile: (schema: unknown) => { (data: unknown): boolean; errors?: ErrorObject[] | null } } }).default; +type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null }; + +type AjvInstance = { + compile: (schema: unknown) => CompiledSchema; + addSchema: (schema: unknown) => unknown; + getSchema: (id: string) => CompiledSchema | undefined; +}; + +const Ajv2020 = (Ajv2020Module as unknown as { default: new (options: Record) => AjvInstance }).default; const addFormats = (addFormatsModule as unknown as { default: (ajv: InstanceType) => void }).default; export type ValidationResult = @@ -18,11 +26,24 @@ export function createValidator() { } const _ajv = createValidator(); -const _compiledValidators = new Map>(); + +// Every schema is registered up front, by $id, so that a schema which $refs +// another one across files resolves. The OpenCreds database schema does this: +// it refers to the item and manifest schemas rather than restating them, and +// restating them is how two copies of a definition drift apart. +for (const schema of Object.values(schemas)) { + _ajv.addSchema(schema); +} + +const _compiledValidators = new Map(); function getCompiledValidator(kind: SchemaKind) { if (!_compiledValidators.has(kind)) { - _compiledValidators.set(kind, _ajv.compile(schemas[kind])); + // Already registered above, so look it up by $id — compiling it a second + // time would throw on the duplicate id. + const id = (schemas[kind] as { $id?: string }).$id; + const registered = id ? _ajv.getSchema(id) : undefined; + _compiledValidators.set(kind, registered ?? _ajv.compile(schemas[kind])); } return _compiledValidators.get(kind)!; } diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index fccf685..d1c8290 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -59,6 +59,15 @@ import ocDecisionSchema from "@logicsrc/schemas/opencontext-decision" with { typ import ocDiagnosticSchema from "@logicsrc/schemas/opencontext-diagnostic" with { type: "json" }; import ocAuditEventSchema from "@logicsrc/schemas/opencontext-audit-event" with { type: "json" }; +// OpenCreds. The database schema $refs the item and manifest schemas by $id, +// so all six are registered together in index.ts before anything is compiled. +import credsItemSchema from "@logicsrc/schemas/opencreds-item" with { type: "json" }; +import credsEnvelopeSchema from "@logicsrc/schemas/opencreds-envelope" with { type: "json" }; +import credsVaultMetaSchema from "@logicsrc/schemas/opencreds-vault-meta" with { type: "json" }; +import credsManifestSchema from "@logicsrc/schemas/opencreds-manifest" with { type: "json" }; +import credsDatabaseSchema from "@logicsrc/schemas/opencreds-database" with { type: "json" }; +import credsAuditEventSchema from "@logicsrc/schemas/opencreds-audit-event" with { type: "json" }; + export const schemas = { agent: agentSchema, "account-audit-event": accountAuditEventSchema, @@ -109,7 +118,13 @@ export const schemas = { "opencontext-provenance": ocProvenanceSchema, "opencontext-decision": ocDecisionSchema, "opencontext-diagnostic": ocDiagnosticSchema, - "opencontext-audit-event": ocAuditEventSchema + "opencontext-audit-event": ocAuditEventSchema, + "opencreds-item": credsItemSchema, + "opencreds-envelope": credsEnvelopeSchema, + "opencreds-vault-meta": credsVaultMetaSchema, + "opencreds-manifest": credsManifestSchema, + "opencreds-database": credsDatabaseSchema, + "opencreds-audit-event": credsAuditEventSchema } as const; export type SchemaKind = keyof typeof schemas; diff --git a/prd/0004-add-logicsrc-opencreds-spec.md b/prd/0004-add-logicsrc-opencreds-spec.md new file mode 100644 index 0000000..4bef40c --- /dev/null +++ b/prd/0004-add-logicsrc-opencreds-spec.md @@ -0,0 +1,185 @@ +--- +openprd: "0.2" +id: "0004" +title: "Add the LogicSRC OpenCreds specification" +status: Draft +authors: + - anthony@profullstack.com +created: 2026-08-29 +updated: 2026-08-29 +repo: profullstack/logicsrc +discussion: +implementation: +tags: + - opencreds + - credentials + - vault + - encryption + - portability + - schemas +supersedes: +superseded-by: +--- + +## Problem + +A password manager is the one application a person is least able to leave. The +vault holds logins, cards, identity documents, private keys and the tokens that +authorize machines to act — and every product stores them in a shape only that +product can read. Leaving means an export, and the export is a CSV. + +The CSV is the whole problem in one file. It is plaintext by construction, so +the act of moving a vault is the act of writing every secret it contains to disk +unencrypted. It is lossy: password history, TOTP seeds, folder structure, custom +fields and attachments have nowhere to go. It disagrees with itself between +products, and between versions of the same product, so each importer is a pile +of column guesses that silently drops whatever it does not recognise. And it +carries no integrity: nothing in a CSV says which rows were meant to be there, +so a truncated import looks exactly like a complete one. + +Underneath that, the same gap shows up inside LogicSRC. `logicsrc credentials` +already moves `.env` secrets and SSH keys through end-to-end-encrypted team +vaults, but it can only model a **key/value pair**. A credit card, a passport, a +login with a TOTP seed and three matching URIs, or an OAuth account with a +refresh token and a scope list are all things people already keep in a vault, +and none of them are a key/value pair. Two Profullstack products — LogicSRC and +MarkSyncr — grew vaults independently and ended up with two item models, two +crypto envelopes, and no way to move a vault between them. + +What is missing is an open, versioned description of **what a credential record +is, how a vault is encrypted, and what a portable vault file looks like** — so +that moving a vault is a supported operation rather than a plaintext export. + +## Goals + +- A person can move an entire vault between two conforming implementations + without a single secret ever being written to disk in plaintext. +- One item model covers what people actually store: logins, cards, identities, + notes, keys and accounts — not key/value pairs plus a comment field. +- An export is lossless and self-describing: password history, TOTP seeds, + folders, custom fields, URI match rules and provenance survive the round trip, + and whatever an importer could not understand is reported rather than dropped. +- An import can be verified before it is trusted: item counts and a digest are + covered by the same authentication tag as the data. +- LogicSRC and MarkSyncr vaults become functionally identical — the same item + types, the same envelope, the same file — with each product's own storage and + UI on top. +- A third-party implementation can conform from the published schemas and + fixtures without reading LogicSRC source. +- Existing vaults stay readable. A deployed vault's domain-separation labels are + baked into its ciphertext and cannot be edited, so the spec carries them as a + declared property rather than pretending every vault in the world started + today. + +## Non-Goals + +- Not a hosted service. A conforming vault is a file plus a key; nothing in the + spec requires an account, a server, or a network call. +- Not a sync protocol. How two devices reconcile is left to the implementation; + OpenCreds defines the record and the file, not the transport. +- Not a new cipher. The spec composes PBKDF2, HKDF and AES-GCM — all available + in WebCrypto — rather than inventing anything. +- Not a browser autofill standard. URI match rules are carried so they survive a + move; how a client fills a form is out of scope. +- Not an attempt to hide vault size. Item type and count are deliberately + observable by a storage server; see the security notes. + +## Users + +- **A person leaving a password manager.** Wants their vault out, intact, and + not in a spreadsheet. +- **A team sharing infrastructure credentials.** Already uses `logicsrc + credentials`; needs to keep a card, a signing key and a service account in the + same vault as the `.env` secrets. +- **An implementer** building a vault who wants interoperability without + reverse-engineering someone's export. +- **An agent** acting on behalf of a person, which must be able to read one + scoped item without being handed the vault. + +## Requirements + +- R1 [P0] Define six item types — `login`, `card`, `identity`, `note`, `key`, + `account` — each as a named field group inside one record shape, with a + versioned schema stamped into every record. +- R2 [P0] Define the item envelope: AES-256-GCM over the JSON record, with the + item id bound as additional authenticated data so a ciphertext cannot be moved + between rows. +- R3 [P0] Define the key hierarchy: a master password stretched by PBKDF2-SHA256 + (600,000 iterations, floor 100,000), split by HKDF into a wrapping key and an + auth hash, wrapping a random 256-bit user key. Argon2id is reserved and carried + in the parameters so it can be adopted without invalidating a vault. +- R4 [P0] Define the portable database: a single JSON file, `.opencreds`, in + either an encrypted form (default) or a plaintext form that must be explicitly + requested and is labelled as unprotected in the file itself. +- R5 [P0] The encrypted database authenticates its own manifest — item count, + type histogram, and a digest over the item ids — so a truncated or tampered + import fails rather than silently importing less than the file claimed. +- R6 [P0] Publish JSON Schemas for the item, the vault meta, the database, the + manifest and the audit event under `@logicsrc/schemas`. +- R7 [P0] Ship a reference implementation, `@logicsrc/opencreds`, with a + conformance suite that runs against published fixtures. +- R8 [P1] Define importers for the CSV exports people actually have — Bitwarden, + 1Password, Chrome, LastPass, KeePass — mapping into the OpenCreds item model, + with unmapped rows reported. +- R9 [P1] Define the CLI surface (`logicsrc vault …` and standalone `opencreds`) + as a conformance surface: flags, output shapes and exit codes. +- R10 [P1] Carry a per-vault `namespace` for domain-separation labels so an + existing vault (`marksyncr`) is conformant without re-encrypting, while a new + vault uses `opencreds`. +- R11 [P1] Define two key-management profiles over one envelope: `user` (a + password-derived key) and `team` (a vault key sealed to each member's public + key, as `logicsrc credentials` already does), so a team vault and a personal + vault hold the same items. +- R12 [P2] Define attachment references so a file attached to an item has a + defined shape, even where an implementation does not yet store blobs. +- R13 [P2] Publish the spec at `logicsrc.com/opencreds` with the schemas linked + from the page. + +## UX Notes + +Export is a deliberate, two-step act. `opencreds export` writes an encrypted +file and says nothing about the passphrase being optional; producing the +plaintext form requires `--plaintext`, which prints what it is about to do and +refuses without `--yes`. The written file carries `"protected": false` in its +header, so a plaintext database is identifiable without parsing the rest of it. + +Import is a preview first. `opencreds import --dry-run` reports the counts +by type, the folders it will create, the duplicates it detected and the rows it +could not map — and only then does an unqualified `import` write. Nothing is +written on a manifest mismatch. + +Failure is per-item, not per-file. One unreadable record must not hide the rest +of a vault, so decryption collects failures and returns them alongside whatever +it recovered. + +## Success Metrics + +- A vault exported from MarkSyncr imports into LogicSRC with byte-identical item + records, and back again, with no plaintext written at any point. +- The conformance suite passes against both implementations from the same + fixtures. +- Every CSV importer round-trips its product's own published sample export with + zero unreported drops. +- A vault created before this spec is readable by a conforming implementation + without re-encryption. + +## Risks & Open Questions + +- **Plaintext export exists at all.** It has to — some people are moving *to* a + product that only reads CSV — and it is the single most dangerous operation in + the spec. Mitigated by making it explicit, labelled and non-default, not by + pretending nobody needs it. +- **PBKDF2 is a compromise.** Argon2id is the better answer and needs WASM in a + browser, which means `wasm-unsafe-eval` in an extension CSP. Carrying the KDF + parameters per vault is what makes the eventual switch a migration rather than + a break. +- **Metadata leaks by design.** A storage server learns how many items of each + type a vault holds. Hiding it costs padding and blind indexes; the spec states + the leak rather than obscuring it. +- **Two profiles, one envelope.** The `team` profile's threat model differs from + `user` — anyone holding the vault key reads everything in it. Open question: + whether per-item re-wrapping is worth specifying for partial sharing, or + whether that belongs to a separate vault. +- **Attachments are specified before they are stored.** Defining the reference + shape now avoids an incompatible retrofit; the risk is specifying a shape the + first real blob store does not fit. diff --git a/prd/README.md b/prd/README.md index 7fdca71..057fda1 100644 --- a/prd/README.md +++ b/prd/README.md @@ -14,3 +14,4 @@ Status lives in each file's front-matter and is the source of truth: | [0001](./0001-add-logicsrc-openontology-spec.md) | Add the LogicSRC OpenOntology specification | Draft | openontology, ontology, knowledge-graph, agents, mcp, schemas | | [0002](./0002-hourly-hire-us-rate.md) | Move Hire Us pricing from a weekly retainer to an hourly rate | Accepted | pricing, site, billing | | [0003](./0003-add-logicsrc-opencontext-spec.md) | Add the LogicSRC OpenContext specification | Draft | opencontext, context, agents, permissions, provenance, schemas | +| [0004](./0004-add-logicsrc-opencreds-spec.md) | Add the LogicSRC OpenCreds specification | Draft | opencreds, credentials, vault, encryption, portability, schemas |