Skip to content

Implement OpenCreds: two more item types, and a portable vault - #22

Merged
ralyodio merged 1 commit into
masterfrom
opencreds-vault
Aug 29, 2026
Merged

Implement OpenCreds: two more item types, and a portable vault#22
ralyodio merged 1 commit into
masterfrom
opencreds-vault

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The vault could hold logins, cards, identities and notes, and could only leave through a CSV. A CSV is plaintext by construction, drops whatever it has no column for — password history, TOTP seeds, folders, custom fields, URI match rules — and carries no integrity: one truncated at 3,000 rows imports 3,000 rows and reports success.

This implements OpenCreds 0.1 (https://logicsrc.com/opencreds), which specifies the record, the envelope and a portable file. Spec PR: profullstack/logicsrc#140.

Two more item types

Both are things people already keep in a vault, and neither is expressible as a login:

  • key (5) — SSH and PGP keys, API tokens, certificates, .env secrets. Carries path and mode, so a restore is total: a private key written back 0644 is a key ssh refuses to use, and one at the wrong path is a key nothing finds.
  • account (6) — a provider account and the OAuth tokens that act 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.

Codes 1–4 are untouched. This vault's deployed codes are what fixed them in the specification; renumbering would break every ciphertext already written. For the same reason the vault declares the namespace marksyncr rather than adopting opencreds — its labels are compiled into the AAD of every ciphertext it has written, so editing one would not migrate a vault, it would make it undecryptable. marksyncr is a registered namespace, and not one existing vault needs re-encrypting.

The portable database, both directions

Encrypted by default under a key derived from an export passphrase, not the vault's user key — a file encrypted under the user key only opens inside the vault it came from, which is the opposite of portable.

The header is bound as AAD over the payload, so the manifest is authenticated by the same tag as the data: the item count is previewable before anyone types a passphrase, and cannot be a lie. Import writes nothing when the passphrase is wrong, the file was altered, or the manifest disagrees with the payload.

UI

Import accepts an .opencreds file alongside the CSVs, prompting for the passphrase with the real item count in the prompt. The editor gains fields for both new types, and vault_items accepts type 5 and 6 in the database (migration) and in the API validator.

Verification

packages/vault/__tests__/opencreds-interop.test.js drives this vault against the reference implementation in both directions and asserts the item records come back byte-identical — a round trip inside one implementation only proves the code agrees with itself. Verified passing against @logicsrc/opencreds: six types out and back, password history, key modes and account scopes intact; wrong passphrases and restated manifests refused.

It skips when the reference is not installed rather than reddening CI. Point OPENCREDS_REF at a built checkout to run it.

  • 149 vault tests (143 + 6 interop), 786 extension tests
  • Lint clean; the pre-commit hook's full lint + build passed across all six packages
  • Web suite matches master exactly — the two extension-auth failures are pre-existing on master and unrelated
  • One existing assertion updated: vault-validation.test.js asserted type 5 was rejected, which is precisely what this widens

🤖 Generated with Claude Code

https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5

The vault could hold logins, cards, identities and notes, and could only
leave through a CSV. A CSV is plaintext by construction, drops whatever
it has no column for -- password history, TOTP seeds, folders, custom
fields, URI match rules -- and carries no integrity: one truncated at
3,000 rows imports 3,000 rows and reports success.

This implements OpenCreds 0.1 (https://logicsrc.com/opencreds), which
specifies the record, the envelope and a portable file.

Two more item types, both things people already keep in a vault and
neither expressible as a login:

- key (5): SSH and PGP keys, API tokens, certificates, .env secrets.
  Carries path and mode, so a restore is total -- a private key written
  back 0644 is a key ssh refuses to use, and one at the wrong path is a
  key nothing finds.
- account (6): a provider account and the OAuth tokens that act 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. Conflating them
  is how a rotated refresh token ends up in a password history array.

Codes 1-4 are untouched. This vault's deployed codes are what fixed them
in the specification; renumbering would break every ciphertext already
written. For the same reason the vault declares the namespace
`marksyncr` rather than adopting `opencreds` -- its labels are compiled
into the AAD of every ciphertext it has written, so editing one would
not migrate a vault, it would make it undecryptable. `marksyncr` is a
registered namespace, and not one existing vault needed re-encrypting.

The portable database, both directions. Encrypted by default under a key
derived from an export passphrase, not the vault's user key -- a file
encrypted under the user key only opens inside the vault it came from.
The header is bound as AAD over the payload, so the manifest is
authenticated by the same tag as the data: the item count is previewable
before anyone types a passphrase, and cannot be a lie. Import writes
nothing when the passphrase is wrong, the file was altered, or the
manifest disagrees with the payload.

Import accepts an .opencreds file alongside the CSVs, prompting for the
passphrase with the real item count in the prompt. The editor gains
fields for both new types, and vault_items accepts type 5 and 6 in the
database and in the API validator.

packages/vault/__tests__/opencreds-interop.test.js drives this vault
against the reference implementation in both directions and asserts the
item records come back byte-identical -- a round trip inside one
implementation only proves the code agrees with itself. It skips when
the reference is not installed rather than reddening CI; point
OPENCREDS_REF at a built checkout to run it. Verified passing against
@logicsrc/opencreds: six types out and back, history, key modes and
account scopes intact, wrong passphrases and restated manifests refused.

149 vault tests, 786 extension tests, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRQrfuwuYKKV5UB9kLHuX5
}),
impl.createItem('account', {
name: 'Stripe',
account: { provider: 'stripe', accessToken: 'sk_live_x', scopes: ['charges:write', 'customers:read'] },
}),
createItem('account', {
name: 'Stripe',
account: { provider: 'stripe', accessToken: 'sk_live_x', scopes: ['charges:write'] },
const userKey = randomBytes(32);
for (const item of [
createItem('key', { name: 'deploy', key: { keyType: 'ssh', privateKey: 'SECRET' } }),
createItem('account', { name: 'Stripe', account: { accessToken: 'sk_live_x' } }),
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

15 finding(s)

MEDIUM: 8 | LOW: 7

Severity Rule Location
MEDIUM js-open-redirect apps/extension/src/blocked/main.js:84
MEDIUM sql-template-interpolation apps/web/app/api/account/delete/route.js:104
MEDIUM js-credential-logged apps/web/app/api/health/route.js:27
MEDIUM js-open-redirect apps/web/app/dashboard/sync-sources-client.jsx:88
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:86
MEDIUM js-unescaped-html-sink apps/web/app/layout.jsx:110
MEDIUM js-open-redirect apps/web/app/pricing/page.jsx:178
MEDIUM manifest-install-lifecycle-script package.json:17
LOW secret-generic-credential apps/web/__tests__/auth-api.test.js:541
LOW redos-nested-quantifier packages/sources/__tests__/dropbox-oauth.test.ts:72
LOW secret-generic-credential packages/vault/__tests__/items.test.js:201
LOW secret-generic-credential packages/vault/__tests__/items.test.js:216
LOW secret-generic-credential packages/vault/__tests__/opencreds-interop.test.js:81
LOW secret-generic-credential packages/vault/__tests__/opencreds.test.js:61
LOW secret-generic-credential packages/vault/__tests__/opencreds.test.js:85

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 34344c1 into master Aug 29, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants