Skip to content

feat(campaigns,tax): generated data keys in the keychain; tax documents encrypted at rest (campaigns 1.0.3, tax 2.1.0) - #513

Merged
kh0pper merged 3 commits into
feat/keychain-restore-and-export-reminderfrom
feat/campaigns-tax-generated-keys
Oct 17, 2026
Merged

kh0pper merged 3 commits into
feat/keychain-restore-and-export-reminderfrom
feat/campaigns-tax-generated-keys

Conversation

@kh0pper

@kh0pper kh0pper commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Stacked on #512. Its base is feat/keychain-restore-and-export-reminder while #512 is open; retarget it to main once #512 merges. This is PR 2 of 2 for the recovery-key decision (queue item 30, Option 1 plus all four extras; no escrow to paired instances).

Changes

  • Generated data keys. CROW_CAMPAIGNS_ENCRYPTION_KEY and CROW_TAX_ENCRYPTION_KEY are now generate: "secret" and keychain: true. A reinstall takes them back from the keychain (feat(keychain): a reinstall restores generated keys from the keychain; Export reminder; federated data keys saved too #512). They reach the MCP child only through its mcp-addons.json entry, never the shared gateway .env.
  • The panels use the same key as the MCP server. Both dashboard panels read the key in the MCP child's own order: the instance's MCP entry first, then the gateway env, matching proxy.js. The panel and the server can never end up on different keys. New helper: bundles/campaigns/server/key.js.
  • Installs on the published example tax key. An install still on the example key once published in the repo now gets a new key. The old key moves to CROW_TAX_ENCRYPTION_KEY_PREVIOUS, so returns saved with it still open and move to the new key.
    • This uses a new generic manifest field, env_vars[].replace_values_sha256, which the bundle contract validates: lowercase hex digests, and it needs both generate and previous_key.
    • A previous_key slot that is already in use is never overwritten.
    • The boot backfill never saves such a value.
  • Tax documents encrypted at rest.
    • Uploads go to <CROW_HOME>/tax-documents as <name>.enc only.
    • Format: AES-256-GCM, PBKDF2 per file, and a key id in the header, so a wrong key is caught before any file is replaced.
    • Ingestion reads the upload from memory; no plaintext is ever written.
    • Uploads are refused when there is no key.
  • Migration of plain documents stored before, run at dashboard start (documents-crypto.js):
    • Each new file gets a unique per-run name. The new file is verified (read back, decrypted, compared) before the row's file_path is updated with compare-and-swap. The plain file is removed only after that.
    • A lock file stops two migrators from running at once.
    • Leftovers from a crash are cleaned only after verification.
    • Only regular files inside the documents folder(s) are ever read, written or deleted. Symlinks and paths from the DB that point elsewhere are skipped. The delete route uses the same check.
    • A row from a crow.db restored under another home is matched by file name in this instance's folder.
  • Reversible. bundles/tax/scripts/documents.mjs:
    • decrypt --in-place is the exact inverse of the migration.
    • decrypt --out <dir> writes plain copies and leaves the DB untouched.
    • CROW_TAX_KEEP_PLAIN_DOCUMENTS=1 stops the start-up pass from encrypting again after a downgrade.
  • The MCP ingest tool can now open a stored .enc file.
  • Refresh fix. A version bump now ships a non-docker bundle's engine/ and pdf/. Tax's panel imports the installed engine, and until now engine/ was never refreshed.
  • Tax manifest wording. It now says exactly what is encrypted (returns, uploaded documents) and what is not (the figures read from documents). It also notes that plain copies may remain in earlier backups and on disk.
  • Tests and versions.
    • bundle-form-friction: the campaigns/tax exemptions are gone. An MCP-server bundle's generated key must be in server.envKeys.
    • Tax's previous_key Configure test now runs on a typed field, plus a new test that Configure never overwrites the generated key.
    • campaigns 1.0.3, tax 2.1.0, registry rebuilt.

Tests

  • tests/tax-documents-crypto.test.js (12 tests):
    • round trip and key id;
    • the example key is refused;
    • migration;
    • crash and sibling cleanup;
    • a lost compare-and-swap;
    • two concurrent migrators;
    • containment (a path outside the folder, a symlink);
    • re-encryption after a key change;
    • a row from another home;
    • reverse in place and export.
  • tests/campaigns-tax-generated-keys.test.js (10 tests):
    • manifest flags and the example-key move;
    • contract validation;
    • panel key order;
    • the tax panel mounted on loopback: upload stored only encrypted, start-up migration, delete containment, no-key refusal;
    • MCP ingest of a .enc file;
    • the refresh dirs.
  • Full suite on this branch: 9751 tests, 1 failure (the tax Configure test, adapted in f432fb2), 0 after the fix. Tax's own bundles/tax/tests give the same result before and after (52 pass, 1 failing integration flow, which predates this branch).

Live proof (scratch home only)

Setup:

  • Every data path, HOME included, was in a session scratch directory, checked before running.
  • No key was in the env.
  • A stub npm ensured nothing was downloaded.
  • A and B were two separate node processes under systemd-run --user -p RuntimeMaxSec=1800, with loopback-only HTTP.
  • The run was registered in the box schedule and cleared after.

Home A:

  1. Upgrade. Tax 2.0.3 (from origin/main) was installed on the example key, with a plain W-2 in the legacy folder.
    • The boot refresh moved it to 2.1.0 and shipped engine/.
    • The backfill did not save the example key.
  2. Reinstall. A new key was generated and the example key moved to _PREVIOUS. No key value appeared in the job log.
  3. Tax panel, mounted from the installed copy:
    • the start-up pass encrypted the old W-2;
    • an upload returned 201;
    • both documents are stored only as .enc, with no plaintext in either documents folder.
    • A return was saved, encrypted.
  4. Campaigns. It installed, and the panel found its generated key in the MCP entry. A Reddit credential was saved, encrypted.
  5. Keychain. It holds both keys.
    • The Nest reminder said "2 saved extension password(s) are not in a keychain Export yet…".
    • The keychain was exported through the real HTTP route after re-auth, and the reminder then went away.
  6. The backup was a consistent VACUUM INTO copy of crow.db plus the .enc files. Home A was then deleted.

Home B:

  1. A reinstall before the Import was refused.
  2. An HTTP Import brought in 2 entries.
  3. The reinstall logged "Restored 1 key(s) from Crow keychain". The tax and campaigns keys were equal to A's (compared by fingerprint).
  4. A's return, both documents and the Reddit credential all decrypted on B.

Licensing

No new third-party component.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

…ts encrypted at rest (campaigns 1.0.3, tax 2.1.0)

Recovery keys, option 1 (queue item 30, decided 2026-10-16). Stacked on the keychain
restore + Export reminder change.

- CROW_CAMPAIGNS_ENCRYPTION_KEY and CROW_TAX_ENCRYPTION_KEY are generated at install and
  saved to the keychain (a reinstall takes them back from it). They reach the MCP child
  through its mcp-addons entry; both dashboard panels read them in the child's own order
  (the entry first, then the gateway env), so panel and server always use one key.
- An install still on the tax example key once published in the repo gets a new key and
  the old one moves to CROW_TAX_ENCRYPTION_KEY_PREVIOUS (generic manifest field
  replace_values_sha256, validated by the bundle contract), so its returns still open
  and move to the new key.
- Tax uploads are stored only encrypted (<CROW_HOME>/tax-documents, AES-256-GCM with a
  key id in the header); ingestion reads the upload from memory. Documents stored before
  are encrypted at dashboard start, one row at a time and verified before the plain file
  is removed: unique per-run names, compare-and-swap on the row, a lock file against two
  migrators, crash leftovers cleaned once verified, and only regular files inside the
  documents folder are ever touched. scripts/documents.mjs reverses it (in place, or as
  plain copies to a folder). The MCP ingest tool opens a stored .enc.
- A version bump now ships a non-docker bundle's engine/ and pdf/ (tax's panel imports
  the installed engine).
- The tax manifest says exactly what is encrypted (returns, documents) and what is not
  (the figures read from documents).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
…ecks the path before reading; restored rows find their .enc; safer lock

- The document key id is now an HMAC under the salted PBKDF2 output (per file), not
  under the passphrase: no cheap offline guess check, nothing linkable across files.
- The MCP ingest tool checks the extension (.pdf/.enc), that the path is a regular file,
  and a 25 MB cap BEFORE reading (the AI chooses the path).
- A crow.db restored from a backup taken before the migration (rows still naming the
  plain file) finds the single <name>.<8hex>.enc successor and repoints the row.
- The documents lock: stale takeover by atomic rename (one winner), touched per row,
  folder tightened to 0700; the panel logs a held lock. --out folders are 0700.
- Upload names get a random part (two same-millisecond uploads never share a file).
- An install on the published example key: the upload refusal and the install log say
  to reinstall, and say why when the previous-key slot is already in use.
- The CLI uses the database of the same CROW_HOME as its key and folders.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
@kh0pper

kh0pper commented Oct 17, 2026

Copy link
Copy Markdown
Owner Author

I ran a fresh code and security review and pushed the fixes in 66bc1fc (rebased on #512's d11b6b2).

  • Document key id. It is now an HMAC under the salted PBKDF2 output, computed per file, not under the passphrase. A stored file no longer offers a cheap offline check for passphrase guesses, and files encrypted with the same key can't be linked to each other.
  • MCP ingest. Before reading, it now checks the file extension (.pdf or .enc), that the path is a regular file, and a 25 MB cap. The AI chooses the path, so /dev/zero, a FIFO, or a symlink to a device is refused without being read.
  • Restored crow.db. If the backup was taken before the migration, its rows still name the plain file. They now find the single <name>.<8hex>.enc that replaced it, and the row is repointed.
  • Documents lock.
    • A stale lock is taken over by an atomic rename, so only one run can win.
    • The lock is touched after each row.
    • The folder is tightened to 0700.
    • The panel logs when the lock is held.
  • Uploads. File names now include a random part, so two uploads in the same millisecond never share a file.
  • Example key.
    • The upload refusal and the install log now tell the user to reinstall.
    • The log also explains why the key was left unchanged when the previous-key slot is already in use.
  • CLI. It now uses the database of the same CROW_HOME as its key and folders.

Results:

  • Tests: full suite 9756 tests, 0 failures.
  • Live proof: re-run in the scratch home after these fixes, and every check passes:
    • upgrade from 2.0.3 through the boot refresh;
    • move off the example key;
    • start-up encryption and upload;
    • Export through the HTTP route; home A deleted;
    • on home B, a reinstall before the Import is refused;
    • Import over HTTP, then reinstall: the keys match A's, and A's return, documents and Reddit credential all decrypt.

https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

@kh0pper
kh0pper merged commit d6966f0 into feat/keychain-restore-and-export-reminder Oct 17, 2026
4 checks passed
kh0pper added a commit that referenced this pull request Oct 17, 2026
…ts encrypted at rest (campaigns 1.0.3, tax 2.1.0) (#513)

* feat(campaigns,tax): generated data keys in the keychain; tax documents encrypted at rest (campaigns 1.0.3, tax 2.1.0)

Recovery keys, option 1 (queue item 30, decided 2026-10-16). Stacked on the keychain
restore + Export reminder change.

- CROW_CAMPAIGNS_ENCRYPTION_KEY and CROW_TAX_ENCRYPTION_KEY are generated at install and
  saved to the keychain (a reinstall takes them back from it). They reach the MCP child
  through its mcp-addons entry; both dashboard panels read them in the child's own order
  (the entry first, then the gateway env), so panel and server always use one key.
- An install still on the tax example key once published in the repo gets a new key and
  the old one moves to CROW_TAX_ENCRYPTION_KEY_PREVIOUS (generic manifest field
  replace_values_sha256, validated by the bundle contract), so its returns still open
  and move to the new key.
- Tax uploads are stored only encrypted (<CROW_HOME>/tax-documents, AES-256-GCM with a
  key id in the header); ingestion reads the upload from memory. Documents stored before
  are encrypted at dashboard start, one row at a time and verified before the plain file
  is removed: unique per-run names, compare-and-swap on the row, a lock file against two
  migrators, crash leftovers cleaned once verified, and only regular files inside the
  documents folder are ever touched. scripts/documents.mjs reverses it (in place, or as
  plain copies to a folder). The MCP ingest tool opens a stored .enc.
- A version bump now ships a non-docker bundle's engine/ and pdf/ (tax's panel imports
  the installed engine).
- The tax manifest says exactly what is encrypted (returns, documents) and what is not
  (the figures read from documents).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* test(tax): the previous_key Configure test runs on a typed field; Configure never overwrites the generated tax key

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(tax): code review — key id from the salted derived key; ingest checks the path before reading; restored rows find their .enc; safer lock

- The document key id is now an HMAC under the salted PBKDF2 output (per file), not
  under the passphrase: no cheap offline guess check, nothing linkable across files.
- The MCP ingest tool checks the extension (.pdf/.enc), that the path is a regular file,
  and a 25 MB cap BEFORE reading (the AI chooses the path).
- A crow.db restored from a backup taken before the migration (rows still naming the
  plain file) finds the single <name>.<8hex>.enc successor and repoints the row.
- The documents lock: stale takeover by atomic rename (one winner), touched per row,
  folder tightened to 0700; the panel logs a held lock. --out folders are 0700.
- Upload names get a random part (two same-millisecond uploads never share a file).
- An install on the published example key: the upload refusal and the install log say
  to reinstall, and say why when the previous-key slot is already in use.
- The CLI uses the database of the same CROW_HOME as its key and folders.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
kh0pper added a commit that referenced this pull request Oct 17, 2026
…, and generated campaigns/tax keys with encrypted tax PDFs (#512, includes #513)

* feat(keychain): a reinstall restores generated keys from the keychain; a reminder to export; federated data keys saved too

Recovery keys, option 1 (queue item 30, decided 2026-10-16): the keychain plus its
passphrase-encrypted Export is how a generated key survives a lost machine.

- A reinstall that finds no .env and no retained copy now takes a keychain:true
  generated key from a readable keychain entry (restored crow.db + imported Export)
  instead of minting a new one, which also overwrote the imported entry and stranded
  data saved with the old key. A store_as key gets a fresh hash of the restored
  plaintext. Best-effort: no key file or an unreadable entry mints, as before, and says
  so by key name. Values are never logged; crow_keychain stays local-only.
- Export reminder: a Nest info issue while a key Crow generated is in no keychain
  Export (created after the last export, a local setting); the install log says so too.
- Mastodon (secret_key_base, OTP secret, three Active Record encryption keys), Pixelfed
  (APP_KEY) and PeerTube (server secret) save their generated keys to the keychain.
  mastodon/pixelfed/peertube 1.0.4.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(keychain): review — never mint over an unreadable entry; boot backfill of kept keys; reminder counts what no Export holds; mcp-addons.json at 600

Plan review findings:
- An install that would mint a keychain:true key whose entry exists but is sealed with
  a keychain key this machine lacks (crow.db restored, Export not imported) is refused:
  the new value would replace that entry and a later Import would skip it, losing the
  old key for good. Deleting the old entry is the explicit way to start fresh.
- Boot backfill: an extension installed before its manifest saved a generated key to
  the keychain gets that key (from .env, retained copy or MCP entry) saved once, so it
  is visible in Passwords and goes into the Export (the boot refresh never runs the
  install job). Never over an existing entry; never a store_as hash.
- The Export reminder fingerprints the sealed values the last Export held (and those an
  Import brought in), so a re-minted or kept key counts and an imported one does not;
  the health cache is dropped on export and import.
- mcp-addons.json (MCP children's generated keys) is written atomically at mode 600.
- The delete warning for a generated entry also covers data keys.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(keychain): code review — fail closed on a keychain read error; a deleted entry stays deleted; the reminder snapshot covers removed extensions

- keychainRestoreValues fails CLOSED: a read error refuses any keychain:true mint
  (it could replace an entry the read missed), and one damaged entry counts as
  unreadable instead of clearing the result.
- The refusal keeps a prior install's folder (its .env holds other secrets) and says
  that deleting needs a dashboard password or two-factor sign-in.
- Boot backfill runs once per bundle+key (local marker): a key the user deletes in
  Passwords is never brought back by the next boot.
- Export fingerprints are taken before the Export is built and cover every status, so
  a reactivated removed extension is not nagged about and a key saved mid-export is.
- The install-log reminder follows the same rule as the Nest (anything unexported).
- Mastodon/Pixelfed/PeerTube database passwords go to the keychain too: a database
  restored from a backup still opens after a reinstall.
- Tests: fail-closed, damaged entry, deleted-stays-deleted, removed extension, and the
  real export/import HTTP routes.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* feat(campaigns,tax): generated data keys in the keychain; tax documents encrypted at rest (campaigns 1.0.3, tax 2.1.0) (#513)

* feat(campaigns,tax): generated data keys in the keychain; tax documents encrypted at rest (campaigns 1.0.3, tax 2.1.0)

Recovery keys, option 1 (queue item 30, decided 2026-10-16). Stacked on the keychain
restore + Export reminder change.

- CROW_CAMPAIGNS_ENCRYPTION_KEY and CROW_TAX_ENCRYPTION_KEY are generated at install and
  saved to the keychain (a reinstall takes them back from it). They reach the MCP child
  through its mcp-addons entry; both dashboard panels read them in the child's own order
  (the entry first, then the gateway env), so panel and server always use one key.
- An install still on the tax example key once published in the repo gets a new key and
  the old one moves to CROW_TAX_ENCRYPTION_KEY_PREVIOUS (generic manifest field
  replace_values_sha256, validated by the bundle contract), so its returns still open
  and move to the new key.
- Tax uploads are stored only encrypted (<CROW_HOME>/tax-documents, AES-256-GCM with a
  key id in the header); ingestion reads the upload from memory. Documents stored before
  are encrypted at dashboard start, one row at a time and verified before the plain file
  is removed: unique per-run names, compare-and-swap on the row, a lock file against two
  migrators, crash leftovers cleaned once verified, and only regular files inside the
  documents folder are ever touched. scripts/documents.mjs reverses it (in place, or as
  plain copies to a folder). The MCP ingest tool opens a stored .enc.
- A version bump now ships a non-docker bundle's engine/ and pdf/ (tax's panel imports
  the installed engine).
- The tax manifest says exactly what is encrypted (returns, documents) and what is not
  (the figures read from documents).

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* test(tax): the previous_key Configure test runs on a typed field; Configure never overwrites the generated tax key

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp

* fix(tax): code review — key id from the salted derived key; ingest checks the path before reading; restored rows find their .enc; safer lock

- The document key id is now an HMAC under the salted PBKDF2 output (per file), not
  under the passphrase: no cheap offline guess check, nothing linkable across files.
- The MCP ingest tool checks the extension (.pdf/.enc), that the path is a regular file,
  and a 25 MB cap BEFORE reading (the AI chooses the path).
- A crow.db restored from a backup taken before the migration (rows still naming the
  plain file) finds the single <name>.<8hex>.enc successor and repoints the row.
- The documents lock: stale takeover by atomic rename (one winner), touched per row,
  folder tightened to 0700; the panel logs a held lock. --out folders are 0700.
- Upload names get a random part (two same-millisecond uploads never share a file).
- An install on the published example key: the upload refusal and the install log say
  to reinstall, and say why when the previous-key slot is already in use.
- The CLI uses the database of the same CROW_HOME as its key and folders.

Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp
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.

1 participant