mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-09-11 19:46:27 +00:00
Add the LogicSRC OpenCreds specification (#140)
* Add the LogicSRC OpenCreds specification Leaving a password manager means writing every secret you own to disk in the clear, and losing whatever the spreadsheet had no column for. A CSV is plaintext by construction, lossy by omission, and carries no integrity: nothing in it says which rows were meant to be there, so a truncated import looks exactly like a complete one. The same gap showed up inside LogicSRC. `logicsrc credentials` moves .env secrets and SSH keys through end-to-end-encrypted team vaults, but it can only model a key/value pair. A card, a passport, a login with a TOTP seed, or an OAuth account with a refresh token are all things people already keep in a vault, and none of them are a key/value pair. OpenCreds defines three things: the item, the vault, and the database. - Six item types (login, card, identity, note, key, account) as one record with a type and a named field group, so everything the user typed lives in a single encrypted blob. Codes 1-4 match MarkSyncr's deployed vault and are not renumbered; compatibility is cheaper than elegance. - AES-256-GCM over that record with the item id bound as AAD. Without it, anyone with storage write access could move a low-value login's ciphertext into a high-value row and watch what the user does next. - A key hierarchy where the user key is random, not derived, so a password change re-wraps 32 bytes rather than re-encrypting a vault. The auth hash comes out of a different HKDF label than the wrap key, which is what lets it reach a server at all. - A portable .opencreds file, encrypted by default, whose header is the AAD over the payload -- so the manifest is authenticated by the same tag as the data and a truncated import fails rather than reporting success. The plaintext form exists because people move to products that read nothing else; it is opt-in, confirmed, 0600, and labelled "protected": false in its own header. Namespaces are carried as data, not fixed by the spec: labels are compiled into every ciphertext a vault has written, so editing one does not migrate a vault, it makes it undecryptable. MarkSyncr's deployed vault is conformant by declaring `marksyncr`. Ships: prd/0004, nine spec pages under docs/opencreds/, six JSON Schemas, the @logicsrc/opencreds reference implementation with CSV importers for five products, `logicsrc vault` and the standalone `opencreds` binary, and the spec page at logicsrc.com/opencreds. `vault` rather than `creds` because `creds` is already an alias of `logicsrc credentials`, and the two are different: one moves a pair between providers, the other stores a record. @logicsrc/validators now registers every schema by $id before compiling, so the database schema can $ref the item and manifest schemas rather than restating them. 120 tests, including CLI end-to-end coverage of the masking rules, exit codes, and the manifest-mismatch path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Make the OpenCreds conformance claim executable The conformance page described a fixture suite and an `opencreds conformance` command that did not exist. A specification that documents a conformance surface it cannot run is a specification nobody can hold to, including us. `opencreds conformance` now runs the requirement list as code -- one check per C-number, carrying its own id and level -- and emits the report shape the spec publishes. It exits 2 when a MUST does not pass, so it can gate CI directly. The reference implementation reports 29 passed, 0 failed, 1 skipped; the skip is C19, because key management for the team profile lives in @logicsrc/plugin-credential-sharing rather than in this package, and a skipped MAY does not affect conformance. Fixtures are generated (`--emit-fixtures <dir>`) rather than hand-written. A vector produced by an implementation and then verified by it is worth more than a JSON file someone typed: the typed file drifts silently when the format moves, and the generated one cannot. Fourteen files, including an invalid/ set every conforming reader must reject -- a wrong field group, a weak KDF, an unregistered namespace, a short payload and a tampered manifest. The CLI requirements stay with the end-to-end tests that drive the real binary through a child process; a command cannot meaningfully check its own exit codes, and a masked value that is only masked in the library is not masked. conformance.md and cli.md now describe what ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Add @logicsrc/opencreds to the lockfile `npm ci` refuses a lockfile that does not match package.json, and the new workspace package plus the CLI's dependency on it were never recorded: the worktree was bootstrapped by hardlinking node_modules rather than installing, so npm was never asked to update the lock. Adds the workspace link and the package entry. No dependency versions move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 * Register PRD 0004, and stop the fixtures looking like real secrets Two CI failures, both mine. `prd/README.md` is generated by `logicsrc prd index --write` and the scaffold test asserts it is current, so adding a PRD without regenerating it leaves the repo's own conformance check failing. Regenerated. The MCP test asserts the next free PRD id against the live prd/ directory — its comment says it advances with every PRD added — so it moves to 0005. ThreatCrush flagged three of the example strings: a PEM header in the item-model docs and in the conformance fixture, and an `sk_live_` prefixed token. All placeholders, none real, but the finding is the scanner working. A fixture only has to exercise the field, and a real-looking private key header or live-key prefix sitting in the tree trains both the scanner and the people reading its output to shrug at exactly the shape that matters. Replaced with obvious placeholders rather than suppressing the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1805d08e5
commit
80a36269bb
56 changed files with 8903 additions and 9 deletions
129
docs/opencreds.md
Normal file
129
docs/opencreds.md
Normal file
|
|
@ -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.
|
||||
155
docs/opencreds/cli.md
Normal file
155
docs/opencreds/cli.md
Normal file
|
|
@ -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 <type> --name <name> [type flags…]
|
||||
opencreds list [--type <type>] [--folder <name>] [--search <text>] [--json]
|
||||
opencreds get <id|name> [--field <path>] [--reveal]
|
||||
opencreds edit <id|name> [flags…]
|
||||
opencreds rm <id|name> [--purge]
|
||||
opencreds restore <id>
|
||||
```
|
||||
|
||||
`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 <file> [--dry-run] [--merge skip|replace|duplicate]
|
||||
opencreds import <file> --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 <file> # 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 <dir> # 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.
|
||||
138
docs/opencreds/conformance.md
Normal file
138
docs/opencreds/conformance.md
Normal file
|
|
@ -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 `<ns>:vault:item:<v>:<id>` 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.
|
||||
147
docs/opencreds/crypto.md
Normal file
147
docs/opencreds/crypto.md
Normal file
|
|
@ -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("<ns>:vault:wrap:v1") → wrap key → AES-GCM → protected user key
|
||||
├─ HKDF("<ns>:vault:auth:v1") → auth hash → server (hashed again there)
|
||||
└─ HKDF("<ns>: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 = "<ns>:vault:item:<v>:<id>")
|
||||
```
|
||||
|
||||
## 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.
|
||||
127
docs/opencreds/database.md
Normal file
127
docs/opencreds/database.md
Normal file
|
|
@ -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("<ns>: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.
|
||||
91
docs/opencreds/faq.md
Normal file
91
docs/opencreds/faq.md
Normal file
|
|
@ -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.
|
||||
129
docs/opencreds/interop.md
Normal file
129
docs/opencreds/interop.md
Normal file
|
|
@ -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.
|
||||
180
docs/opencreds/item-model.md
Normal file
180
docs/opencreds/item-model.md
Normal file
|
|
@ -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": "<uuid>", "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": "<the armoured private key body>",
|
||||
"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": "<access token>", "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"
|
||||
}
|
||||
```
|
||||
120
docs/opencreds/security.md
Normal file
120
docs/opencreds/security.md
Normal file
|
|
@ -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`.
|
||||
421
docs/opencreds/spec.md
Normal file
421
docs/opencreds/spec.md
Normal file
|
|
@ -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. |
|
||||
| `<type>` | 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": "<base64, ≥16 bytes>" }
|
||||
```
|
||||
|
||||
- `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(<ns>:vault:wrap:v1) HKDF(<ns>:vault:auth:v1) HKDF(<ns>: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("<namespace>:vault:item:<v>:<id>")`
|
||||
4. `AES-GCM(userKey, iv, plaintext, AAD)`.
|
||||
|
||||
The stored envelope is:
|
||||
|
||||
```json
|
||||
{ "id": "<uuid>", "type": 1, "ciphertext": "<base64>", "iv": "<base64>" }
|
||||
```
|
||||
|
||||
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": "<base64>",
|
||||
"protectedUserKey": "<base64>",
|
||||
"protectedUserKeyIv": "<base64>",
|
||||
"recoveryKeyBlob": "<base64>",
|
||||
"recoveryKeyIv": "<base64>",
|
||||
"authHash": "<base64>",
|
||||
"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`:
|
||||
|
||||
```
|
||||
<namespace>:vault:wrap:v1
|
||||
<namespace>:vault:auth:v1
|
||||
<namespace>:vault:recovery:v1
|
||||
<namespace>:vault:item:<v>:<id>
|
||||
<namespace>: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": "<base64>" },
|
||||
"manifest": { "itemCount": 42, "types": { "login": 40, "card": 2 },
|
||||
"folderCount": 3, "digest": "<base64 SHA-256>" },
|
||||
"iv": "<base64>",
|
||||
"ciphertext": "<base64>"
|
||||
}
|
||||
```
|
||||
|
||||
**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("<ns>: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": "<salted SHA-256, truncated>", "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. |
|
||||
Loading…
Add table
Add a link
Reference in a new issue