From b845ff176d5467a960228fffc1f170dcfe97c766 Mon Sep 17 00:00:00 2001 From: Daniel Yang Date: Sat, 18 Jul 2026 10:10:26 -0700 Subject: [PATCH] Add pkvault seal/unseal: encrypt any file with the vault identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean rebuild of the seal-unseal branch from main containing only the pkvault/crypto/spec work (the previous branch had accidentally swept in unrelated pklinks files from a shared working tree). pkvault seal / unseal wrap the PKFILE1 envelope (SPEC-FILE new §7.1: canonical single-file serialization, header then chunks in index order, no trailing bytes, .pkfile). Works anywhere on disk — a repo only upgrades the default recipient set: --recipient flags → manifest → yourself, with the chosen set always echoed. Sealing uses only public recipients (no unlock, write-only); unsealing resolves the per-file key through the agent's new op:"unseal-file" — the identity scalar never crosses the socket. @pksuite/crypto gains an unwrapFileKey alternative to identity in openFileEnvelope (copies in, copies out) and bumps to 0.2.0. Durability and data-loss hardening (review rounds 1–2): - input/output aliases detected by device/inode (case-insensitive filesystems, hard links) and refused even with --force, both commands - the crypto state is created after the input opens, geometry comes from fstat of the opened descriptor, and disposal runs before close on every failure path — a close error can never skip key disposal - all writes loop via txn.writeAllSync (short writes) - no-force installs use link(2): a destination created after the precheck fails E_EXISTS instead of being replaced by rename - destination directory fsynced after install (POSIX durability) - finalization inside the atomic window; 0600 plaintext outputs; input-mutation detection while sealing; no temp litter on failure Tests: crypto 21/21; pkvault 228/228 including hard-link and case-folded alias refusals and a race test that reaches the actual link(2) window via an injected destination. --- apps/pkvault/CHANGELOG.md | 10 ++ apps/pkvault/README.md | 21 ++++ apps/pkvault/package.json | 2 +- apps/pkvault/src/agent.js | 20 +++ apps/pkvault/src/bin.js | 15 +++ apps/pkvault/src/cli.js | 190 ++++++++++++++++++++++++++++- apps/pkvault/src/txn.js | 2 +- apps/pkvault/test/agent.test.js | 31 +++++ apps/pkvault/test/pkfile.test.js | 162 ++++++++++++++++++++++++ packages/crypto/README.md | 6 + packages/crypto/dist/SPEC-FILE.md | 17 +++ packages/crypto/dist/index.cjs | 6 +- packages/crypto/dist/index.cjs.map | 4 +- packages/crypto/dist/index.d.ts | 10 +- packages/crypto/dist/index.js | 6 +- packages/crypto/dist/index.js.map | 4 +- packages/crypto/package.json | 2 +- packages/crypto/src/file.js | 12 +- packages/crypto/src/index.d.ts | 10 +- packages/crypto/test/file.test.js | 33 +++++ pnpm-lock.yaml | 2 +- spec/SPEC-FILE.md | 17 +++ 22 files changed, 565 insertions(+), 17 deletions(-) create mode 100644 apps/pkvault/test/pkfile.test.js diff --git a/apps/pkvault/CHANGELOG.md b/apps/pkvault/CHANGELOG.md index 64b164f..041e920 100644 --- a/apps/pkvault/CHANGELOG.md +++ b/apps/pkvault/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **`pkvault seal ` / `pkvault unseal `**: encrypt any + file on disk to age recipients using the PKFILE1 envelope + (`@pksuite/crypto` 0.2.0, SPEC-FILE §7.1). Recipient defaults: explicit + `--recipient` flags → repo manifest → yourself; the chosen set is always + echoed. Streaming chunked I/O, atomic outputs, no-overwrite unless + `--force`. Unsealing resolves the per-file key through the agent + (`op:"unseal-file"`) — the identity scalar still never leaves the agent. + ## 0.1.1 (pre-release) - **Security: the agent no longer releases the identity scalar.** The unix diff --git a/apps/pkvault/README.md b/apps/pkvault/README.md index f3e6571..c803e8e 100644 --- a/apps/pkvault/README.md +++ b/apps/pkvault/README.md @@ -87,6 +87,27 @@ everywhere. The vault format, `init`, `get`, `run`, `add`/`remove`, and On POSIX systems, transaction files, renames, and containing directories are fsynced and failures are propagated. Node does not expose directory fsync on Windows, so Windows currently provides process-crash consistency but not the same power-loss durability guarantee. +## Sealing arbitrary files + +`seal` and `unseal` encrypt any file on disk with the same identity that +guards your vaults, using the PKFILE1 envelope from `@pksuite/crypto` +(SPEC-FILE §7.1 single-file serialization, `.pkfile` extension): + +```sh +pkvault seal dump.sql # outside a repo: sealed to YOU only +pkvault seal report.pdf # inside a repo: sealed to the manifest recipients +pkvault seal notes.txt --recipient age1… # ad-hoc recipients (repeatable) +pkvault unseal dump.sql.pkfile # output name comes from the sealed metadata +``` + +The recipient set used is always stated in the output — check it before +sharing a sealed file. Sealing needs no unlock unless it defaults to you +(it only uses public recipients); unsealing unlocks through the agent, +which unwraps the per-file key without ever releasing your identity. +Sealed files are snapshots: removing a recipient from a vault later does +not re-protect files sealed while they were a recipient. Scope is +deliberately narrow — single files, no folder sync, no watch modes. + ## Editing and plaintext exposure `pkvault set` (stdin) is the strict path — no plaintext buffer ever hits diff --git a/apps/pkvault/package.json b/apps/pkvault/package.json index 9cbbbb5..60c5117 100644 --- a/apps/pkvault/package.json +++ b/apps/pkvault/package.json @@ -41,6 +41,6 @@ "node": ">=18" }, "dependencies": { - "@pksuite/crypto": "0.1.0" + "@pksuite/crypto": "0.2.0" } } diff --git a/apps/pkvault/src/agent.js b/apps/pkvault/src/agent.js index 010ce4a..10b3460 100644 --- a/apps/pkvault/src/agent.js +++ b/apps/pkvault/src/agent.js @@ -142,6 +142,26 @@ function startAgent({ home, label, repoRoot = null, ttlMs = 12 * 3600 * 1000, br return { fk: fk.toString("base64"), label, recipient }; }); } + case "unseal-file": { + // Unwraps the 16-byte file key from a PKFILE1 envelope's age header + // (pkvault seal/unseal). Like op:"unseal", the scalar never leaves + // the agent — only the per-file key does. + const ageHeader = Buffer.from(String(msg.ageHeader ?? ""), "base64"); + if (ageHeader.length === 0 || ageHeader.length > 256 * 1024) + throw err("E_AGENT_PROTO", "unseal-file needs ageHeader (base64, at most 256 KiB)"); + return withUnlock(() => { + const shared = require("@pksuite/crypto"); + let fileKey; + try { + fileKey = shared.ageHeader.open(new Uint8Array(ageHeader), new Uint8Array(scalar)); + } catch (e) { + throw err(e.code ?? "E_ENVELOPE", e.message); + } + const reply = { fileKey: Buffer.from(fileKey).toString("base64"), label, recipient }; + fileKey.fill(0); + return reply; + }); + } case "status": return { unlocked: unlocked(), label, expiresAt: unlocked() ? expiresAt : null }; case "lock": diff --git a/apps/pkvault/src/bin.js b/apps/pkvault/src/bin.js index 5ab5cc6..b8e587b 100644 --- a/apps/pkvault/src/bin.js +++ b/apps/pkvault/src/bin.js @@ -187,6 +187,10 @@ const USAGE = `pkvault — passkey-encrypted .env files for teams (https://pkvau remove