Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/pkvault/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- **`pkvault seal <file>` / `pkvault unseal <file.pkfile>`**: 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
Expand Down
21 changes: 21 additions & 0 deletions apps/pkvault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/pkvault/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@
"node": ">=18"
},
"dependencies": {
"@pksuite/crypto": "0.1.0"
"@pksuite/crypto": "0.2.0"
}
}
20 changes: 20 additions & 0 deletions apps/pkvault/src/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
15 changes: 15 additions & 0 deletions apps/pkvault/src/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ const USAGE = `pkvault — passkey-encrypted .env files for teams (https://pkvau
remove <label> [--accept-exposure]
status
export [--to <path> --i-want-plaintext-on-disk] # reverse adoption: stdout free, disk gated
seal <file> [--recipient <age1…>]… [--to <out>] [--name <n>] [--force]
encrypt any file (PKFILE1). Defaults: in a repo →
manifest recipients; otherwise → you only
unseal <file.pkfile> [--to <out>] [--force] # decrypt; output name from sealed metadata
unlock --force

identity: passkey agent by default; PKVAULT_IDENTITY or PKVAULT_IDENTITY_FILE for machine/CI identities.`;
Expand Down Expand Up @@ -302,6 +306,17 @@ async function main() {
end(args, 0);
return cli.exportPlain({ cwd, io, env, to, iWantPlaintextOnDisk });
}
case "seal": {
const to = opt(args, "--to"), name = opt(args, "--name"), force = flag(args, "--force") === true;
const recipients = optAll(args, "--recipient");
end(args, 1);
return cli.seal({ cwd, io, env, file: args[0], to, name, force, recipients });
}
case "unseal": {
const to = opt(args, "--to"), force = flag(args, "--force") === true;
end(args, 1);
return cli.unseal({ cwd, io, env, file: args[0], to, force });
}
case "unlock": {
const force = flag(args, "--force") === true;
end(args, 0);
Expand Down
190 changes: 189 additions & 1 deletion apps/pkvault/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ function resolveIdentity(env = process.env) {
if (reply?.code === "E_UNSEAL") throw new Error("ENVELOPE_NO_MATCH");
throw err("E_NO_IDENTITY", `agent: ${reply?.message ?? reply?.code ?? "bad agent reply"}`);
},
unsealFile(ageHeader) {
const reply = ask({ op: "unseal-file", ageHeader: Buffer.from(ageHeader).toString("base64") });
if (reply?.ok) return Buffer.from(reply.fileKey, "base64");
if (reply?.code) throw err(reply.code, reply.message ?? reply.code);
throw err("E_NO_IDENTITY", "agent: bad agent reply");
},
};
if (first?.code && first.code !== "E_AGENT_UNAVAILABLE")
throw err("E_NO_IDENTITY", `agent: ${first.message ?? first.code}`);
Expand Down Expand Up @@ -1006,9 +1012,191 @@ async function ceremonyUnlock({ label, cwd = null, home = defaultHome(), bridgeO
return r.scalar;
}

// --- seal / unseal: PKFILE1 single files (SPEC-FILE §7.1) --------------------
// Works anywhere on disk: a pkvault repo only upgrades the default recipient
// set from "you" to the manifest. Sealing needs no unlock unless self-sealing.
const pkfile = require("@pksuite/crypto");
const PKFILE_EXT = ".pkfile";
const PKFILE_PRELUDE = 12; // 8-byte magic + u32 header body length
const PKFILE_MAX_HEADER = 1024 * 1024;

function identityRecipient(env) {
const id = resolveIdentity(env);
return id.agent === true ? id.recipient : agelib.encodeRecipient(agelib.publicFromScalar(id));
}

function sealRecipientSet({ cwd, recipients, env }) {
if (recipients.length > 0) {
for (const r of recipients) pkfile.parseRecipient(r); // fail early, named
return { list: recipients, source: `${recipients.length} recipient(s) from --recipient` };
}
let root = null;
try { root = findRoot(cwd); } catch (e) { if (e.code !== "E_NOT_INITIALIZED") throw e; }
if (root) {
const manifest = loadManifest(root);
if (manifest.length > 0)
return { list: manifest.map((r) => r.recipient), source: `${manifest.length} manifest recipient(s): ${manifest.map((r) => r.label).join(", ")}` };
}
return { list: [identityRecipient(env)], source: "you only" };
}

// String equality cannot identify the same file on case-insensitive
// filesystems (macOS default) or through hard links — compare device/inode
// of the opened input against any existing destination instead. Refused even
// with --force: replacing the plaintext input with its own envelope (or a
// sealed file with its own plaintext) is unrecoverable.
function assertDistinctFile(inFd, outAbs) {
let outStat = null;
try { outStat = fs.statSync(outAbs); } catch { return; }
const inStat = fs.fstatSync(inFd);
if (outStat.dev === inStat.dev && outStat.ino === inStat.ino)
throw err("E_PATH_RESERVED", "output must differ from the input file");
}

// Streamed atomic write: temp in the destination directory, wx (no clobber of
// a concurrent temp), full writes, fsync, then install. Without --force the
// install is link(2), which fails EEXIST instead of replacing a destination
// created by another process after the precheck; --force installs by rename.
// The destination directory is fsynced so the install survives power loss.
function withAtomicOutput(outAbs, force, writer) {
txn.assertRegularFileOrAbsent(outAbs);
if (!force && fs.existsSync(outAbs)) throw err("E_EXISTS", `${outAbs} exists — pass --force to overwrite`);
const tmp = `${outAbs}.pkvault-tmp-${process.pid}`;
const fd = fs.openSync(tmp, "wx", 0o600);
let installed = false;
try {
writer((bytes) => txn.writeAllSync(fd, bytes));
fs.fsyncSync(fd);
fs.closeSync(fd);
if (force) {
fs.renameSync(tmp, outAbs);
} else {
try {
fs.linkSync(tmp, outAbs);
} catch (e) {
if (e.code === "EEXIST") throw err("E_EXISTS", `${outAbs} exists — pass --force to overwrite`);
throw e;
}
fs.unlinkSync(tmp);
}
installed = true;
txn.fsyncDir(path.dirname(outAbs));
} finally {
if (!installed) {
try { fs.closeSync(fd); } catch { /* already closed */ }
try { fs.unlinkSync(tmp); } catch { /* never created or already installed */ }
}
}
}

function chunkPlaintextLength(state, index) {
if (state.plaintextSize === 0) return 0;
return index === state.chunkCount - 1 ? state.plaintextSize - state.chunkSize * index : state.chunkSize;
}

function seal({ cwd, io, file, to = null, recipients = [], name = null, force = false, env }) {
const inAbs = path.resolve(cwd, file);
txn.assertRegularFileOrAbsent(inAbs);
if (!fs.existsSync(inAbs)) throw err("E_NO_INPUT", `no such file: ${inAbs}`);
const { list, source } = sealRecipientSet({ cwd, recipients, env });
const outAbs = to ? path.resolve(cwd, to) : `${inAbs}${PKFILE_EXT}`;
// Open FIRST: geometry comes from fstat of the opened descriptor (no
// stat/open race), and the crypto state is created inside the cleanup
// scope so its file key is disposed on EVERY failure path.
const fd = fs.openSync(inAbs, "r");
let state = null;
let sealed = false;
try {
const stat = fs.fstatSync(fd);
const lastModified = Number.isSafeInteger(Math.floor(stat.mtimeMs)) && stat.mtimeMs >= 0 ? Math.floor(stat.mtimeMs) : null;
state = pkfile.createFileEncryption({
recipients: list,
plaintextSize: stat.size,
metadata: { name: name ?? path.basename(inAbs), lastModified },
});
assertDistinctFile(fd, outAbs);
withAtomicOutput(outAbs, force, (write) => {
write(state.header);
for (let index = 0; index < state.chunkCount; index++) {
const plaintext = Buffer.alloc(chunkPlaintextLength(state, index));
const got = fs.readSync(fd, plaintext, 0, plaintext.length, null);
if (got !== plaintext.length) throw err("E_INPUT_CHANGED", "input file shrank while sealing");
write(pkfile.encryptChunk({ state, index, plaintext: new Uint8Array(plaintext.buffer, plaintext.byteOffset, plaintext.length) }));
plaintext.fill(0);
}
const extra = fs.readSync(fd, Buffer.alloc(1), 0, 1, null);
if (extra !== 0) throw err("E_INPUT_CHANGED", "input file grew while sealing");
pkfile.finalizeEnvelope(state); // every chunk authenticated before the install commits
});
sealed = true;
} finally {
// dispose BEFORE close so a close error can never skip key disposal;
// failure to close a read-only descriptor risks nothing
if (!sealed && state) {
try { pkfile.disposeEnvelope(state); } catch { /* already finalized or disposed */ }
}
try { fs.closeSync(fd); } catch { /* read-only fd */ }
}
io.out(`sealed ${path.basename(inAbs)} → ${outAbs} (${source})`);
}

function unseal({ cwd, io, file, to = null, force = false, env }) {
const inAbs = path.resolve(cwd, file);
txn.assertRegularFileOrAbsent(inAbs);
if (!fs.existsSync(inAbs)) throw err("E_NO_INPUT", `no such file: ${inAbs}`);
const fd = fs.openSync(inAbs, "r");
try {
const prelude = Buffer.alloc(PKFILE_PRELUDE);
if (fs.readSync(fd, prelude, 0, PKFILE_PRELUDE, 0) !== PKFILE_PRELUDE)
throw err("E_ENVELOPE_PARSE", "not a PKFILE1 file (truncated prelude)");
const bodyLength = prelude.readUInt32BE(8);
if (bodyLength > PKFILE_MAX_HEADER) throw err("E_ENVELOPE_PARSE", "file envelope header is too large");
const header = Buffer.alloc(PKFILE_PRELUDE + bodyLength);
if (fs.readSync(fd, header, 0, header.length, 0) !== header.length)
throw err("E_ENVELOPE_PARSE", "not a PKFILE1 file (truncated header)");
const id = resolveIdentity(env);
const state = pkfile.openFileEnvelope(
id.agent === true
? { unwrapFileKey: (ageHeader) => id.unsealFile(ageHeader), header: new Uint8Array(header) }
: { identity: new Uint8Array(id), header: new Uint8Array(header) },
);
const outAbs = to ? path.resolve(cwd, to) : path.join(path.dirname(inAbs), state.metadata.name);
let offset = header.length;
let unsealed = false;
try {
assertDistinctFile(fd, outAbs);
withAtomicOutput(outAbs, force, (write) => {
for (let index = 0; index < state.chunkCount; index++) {
const length = 24 + chunkPlaintextLength(state, index) + 16;
const ciphertext = Buffer.alloc(length);
if (fs.readSync(fd, ciphertext, 0, length, offset) !== length)
throw err("E_ENVELOPE_PARSE", `truncated chunk ${index}`);
offset += length;
const plaintext = pkfile.decryptChunk({ state, index, ciphertext: new Uint8Array(ciphertext.buffer, ciphertext.byteOffset, length) });
write(plaintext);
plaintext.fill(0);
}
if (fs.readSync(fd, Buffer.alloc(1), 0, 1, offset) !== 0)
throw err("E_ENVELOPE_PARSE", "trailing bytes after the final chunk");
pkfile.finalizeEnvelope(state); // every chunk authenticated before the install commits
});
unsealed = true;
} finally {
if (!unsealed) {
try { pkfile.disposeEnvelope(state); } catch { /* already finalized or disposed */ }
}
}
io.out(`unsealed → ${outAbs}`);
} finally {
// disposal already ran in the inner finally; a read-only close error
// must not turn a completed unseal into a reported failure
try { fs.closeSync(fd); } catch { /* read-only fd */ }
}
}

module.exports = {
CliError, findRoot, init, get, set, run, classify, add, remove, status,
exportPlain, localInit, unlockForce, fingerprint, resolveIdentity,
setup, recover, ceremonyUnlock, labelOrDefault, setDefaultLabel, readDefaultLabel,
setDefaultRpId, readDefaultRpId, rpIdOrDefault, edit,
setDefaultRpId, readDefaultRpId, rpIdOrDefault, edit, seal, unseal,
};
2 changes: 1 addition & 1 deletion apps/pkvault/src/txn.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,5 +389,5 @@ function cleanupStale(repoRoot, configuredTargets, { isTracked = defaultIsTracke
module.exports = {
TxnError, strictJsonParse, validateRelPath, validateAncestors,
assertRegularFileOrAbsent, validateMarker, acquireLock, forceUnlock,
writeAllSync, writeFileAtomic, removeFileDurable, commitTxn, recover, cleanupStale,
writeAllSync, writeFileAtomic, removeFileDurable, commitTxn, recover, cleanupStale, fsyncDir,
};
31 changes: 31 additions & 0 deletions apps/pkvault/test/agent.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,34 @@ test("end to end: sync CLI commands resolve identity through a real agent proces
child.kill();
}
});

test("end to end: unseal resolves the PKFILE1 file key through the agent (op unseal-file)", async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "pkvault-agent-"));
const ceremonies = { n: 0 };
const { recipient } = await setUpIdentity(home, ceremonies);

const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pkvault-agent-pkfile-"));
const payload = crypto.randomBytes(150000);
fs.writeFileSync(path.join(cwd, "doc.bin"), payload);
const io = { out: () => {}, interactive: true, confirm: () => true, txnOpts: { isTracked: () => false } };
// sealing needs only the recipient — no unlock, write-only
cli.seal({ cwd, io, env: { PKVAULT_HOME: home }, file: "doc.bin", recipients: [recipient] });
assert.equal(ceremonies.n, 1, "sealing runs no ceremony");

const { spawn } = require("node:child_process");
const child = spawn(process.execPath, [path.join(__dirname, "../scripts/sim-agent.js"), home, "daniel"], { stdio: ["ignore", "pipe", "inherit"] });
await new Promise((resolve, reject) => {
child.stdout.on("data", (d) => d.toString().includes("READY") && resolve());
child.on("exit", (c) => reject(new Error(`sim-agent exited ${c}`)));
setTimeout(() => reject(new Error("sim-agent never became ready")), 10000).unref();
});
const count = () => parseInt(fs.readFileSync(path.join(home, "ceremonies.count"), "utf8"), 10);
try {
fs.unlinkSync(path.join(cwd, "doc.bin"));
cli.unseal({ cwd, io, env: { PKVAULT_HOME: home }, file: "doc.bin.pkfile" });
assert.deepEqual(fs.readFileSync(path.join(cwd, "doc.bin")), payload);
assert.equal(count(), 1, "unseal triggered exactly one agent unlock ceremony");
} finally {
child.kill();
}
});
Loading
Loading