run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
865 lines • 42.3 kB
JavaScript
/**
* vault — the principal keystore (protocol rev 41 §5.1; task 5.2).
*
* Layout (under the active profile's config dir, beside the project-key cache
* `core/src/keystore.ts` manages — `RUN402_CONFIG_DIR` / `RUN402_WALLET` aware):
*
* <config>/vault/
* identity.json Ed25519 seed + X25519 private key (0600)
* repos/<repo_id>.json K_repo, trust pin, dual pins, last ref transaction (0600)
* repos/<repo_id>.lock/ per-repo lock (mkdir-atomic, 0700)
* receipts/<repo_id>.recovery_receipt.json the recovery receipt (NOT a secret; more copies better)
* journal/<client_creation_id>.json six-stage creation journals (task 5.3)
* audit.log append-only JSON lines (0600)
*
* Discipline (spec: "Writes SHALL be atomic with per-repo locks, no-follow
* creation, and permission audits"): directories 0700, files 0600, every
* write = temp file (O_NOFOLLOW|O_EXCL) + fsync + rename + directory fsync,
* and `auditKeystorePermissions()` runs at open so a world-readable identity
* is a named finding, never silent.
*
* Transitions (protocol §5.1 partial-loss table) are DERIVED by
* {@link VaultKeystore.assess} and acted on by explicit methods — the
* keystore never guesses:
* - repo file lost + identity intact → `restoreRepoFromEnvelope` (own envelope)
* - signing key lost, K_repo held → `read_only` (clear report)
* - stale pin → `stale_pin` (re-verify from genesis; 5.4)
* - whole keystore lost → `VAULT_UNRECOVERABLE` (§0, verbatim)
*/
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeSync } from "node:fs";
import { dirname, join } from "node:path";
import { getConfigDir } from "../../core-dist/config.js";
import { LocalError } from "../errors.js";
import { VAULT_EK_RE, VAULT_SRC_RE, VAULT_TERMINAL_LOSS_DOCTOR_TEXT, VAULT_TERMINAL_LOSS_STATEMENT, VAULT_VK_RE, bytesToHex, checkGenesisKeyBindings, checkRecoveryReceipt, ekFingerprint, fromBase64url, formatVaultTimestamp, generateEncryptionKeypair, generateSigningKeypair, hexToBytes, openKeyEnvelope, storedBytesSha256, toBase64url, verifyVaultObject, vkFingerprint, } from "../namespaces/vault.crypto.js";
function fail(code, message, context, details) {
throw new LocalError(message, context, { code, details });
}
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
/** The local object cache's recency window (design D3: "genesis + newest N=8 heads + newest carriers"). */
export const VAULT_OBJECT_CACHE_WINDOW = 8;
/** vault-object-host-predial (design D1): the max persisted object-store origins per repo — small on purpose, this is a predial hint, not an inventory. */
export const VAULT_OBJECT_STORE_ORIGINS_CAP = 4;
/** Bare hex16 generation comparison for cache eviction — deliberately NOT `generationToBigInt` (vault-publication.ts), which this lower-level module must not import (publication already imports FROM here). Malformed input sorts as "evict" rather than throwing: a corrupt cache entry should never block eviction of everything else. */
function genToBigInt(generation) {
try {
return /^[0-9a-f]{16}$/.test(generation) ? BigInt(`0x${generation}`) : -1n;
}
catch {
return -1n;
}
}
/** The keystore directory's earlier name under the same config dir. */
const FORMER_KEYSTORE_DIRNAME = "gitvault";
/**
* Default keystore root: `<active profile config dir>/vault`.
*
* This is the one place the keystore path is first resolved, so it is also
* where a keystore still at `<config>/gitvault` moves to `<config>/vault`:
* one same-directory `rename`, which is atomic, and only when `vault` does
* not exist yet. Nothing reads the earlier path after that. A move another
* process already made is not an error; any other refusal throws
* `VAULT_KEYSTORE_MOVE_FAILED` naming both paths, because answering with an
* empty `vault` directory would make a live keystore look lost.
*/
export function getVaultKeystoreRoot() {
const configDir = getConfigDir();
const root = join(configDir, "vault");
moveFormerKeystoreDir(join(configDir, FORMER_KEYSTORE_DIRNAME), root);
return root;
}
function moveFormerKeystoreDir(former, root) {
if (existsSync(root))
return;
let stat;
try {
stat = lstatSync(former);
}
catch {
return; // nothing to move
}
if (!stat.isDirectory() && !stat.isSymbolicLink())
return;
try {
renameSync(former, root);
}
catch (e) {
const code = e.code;
if (existsSync(root) || code === "ENOENT")
return; // another process moved it first
throw new LocalError(`could not move the vault keystore from ${former} to ${root}: ${e instanceof Error ? e.message : String(e)}`, "resolving the vault keystore", { code: "VAULT_KEYSTORE_MOVE_FAILED", details: { from: former, to: root }, cause: e });
}
}
function mkdir0700(path) {
mkdirSync(path, { recursive: true, mode: DIR_MODE });
}
function fsyncDir(path) {
try {
const fd = openSync(path, constants.O_RDONLY);
try {
fsyncSync(fd);
}
finally {
closeSync(fd);
}
}
catch {
/* some filesystems refuse directory fsync; the rename is still atomic */
}
}
/**
* Atomic, no-follow, fsynced write: `O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW` on a
* temp sibling, fsync, rename over the target, fsync the directory.
*/
export function writeFileAtomic0600(path, text) {
const dir = dirname(path);
mkdir0700(dir);
if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
fail("VAULT_KEYSTORE_SYMLINK", `refusing to write through a symlink: ${path}`, "writing vault keystore file", { path });
}
const tmp = join(dir, `.${String(process.pid)}.${bytesToHex(globalThis.crypto.getRandomValues(new Uint8Array(6)))}.tmp`);
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW, FILE_MODE);
try {
writeSync(fd, text);
fsyncSync(fd);
}
finally {
closeSync(fd);
}
renameSync(tmp, path);
fsyncDir(dir);
}
/** No-follow read: a symlinked keystore file is a refusal, not a read. */
export function readFileNoFollow(path) {
let st;
try {
st = lstatSync(path);
}
catch {
return null;
}
if (st.isSymbolicLink()) {
fail("VAULT_KEYSTORE_SYMLINK", `refusing to read through a symlink: ${path}`, "reading vault keystore file", { path });
}
const fd = openSync(path, constants.O_RDONLY | O_NOFOLLOW);
try {
return readFileSync(fd, "utf8");
}
finally {
closeSync(fd);
}
}
function auditPath(path, expectDir) {
const findings = [];
let st;
try {
st = lstatSync(path);
}
catch {
return [{ path, problem: "missing" }];
}
if (st.isSymbolicLink())
findings.push({ path, problem: "symlink" });
if (process.platform !== "win32") {
const mode = st.mode & 0o777;
const allowed = expectDir ? DIR_MODE : FILE_MODE;
if ((mode & ~allowed) !== 0)
findings.push({ path, problem: "world_or_group_accessible", mode: mode.toString(8) });
if (typeof process.getuid === "function" && st.uid !== process.getuid())
findings.push({ path, problem: "not_owned_by_user" });
}
return findings;
}
// ─── The keystore ────────────────────────────────────────────────────────────
export class VaultKeystore {
rootDir;
now;
strictPermissions;
constructor(options = {}) {
this.rootDir = options.rootDir ?? getVaultKeystoreRoot();
this.now = options.now ?? (() => new Date());
this.strictPermissions = options.strictPermissions ?? false;
}
/** Open (creating the directory skeleton) and run the permission audit. */
static open(options = {}) {
const ks = new VaultKeystore(options);
for (const dir of [ks.rootDir, ks.reposDir, ks.receiptsDir, ks.journalDir])
mkdir0700(dir);
const findings = ks.auditPermissions();
ks.audit("keystore_opened", undefined, { findings: findings.length });
if (findings.length > 0 && ks.strictPermissions) {
fail("VAULT_KEYSTORE_PERMISSIONS", "vault keystore permission audit failed", "opening vault keystore", { findings });
}
return ks;
}
get identityPath() { return join(this.rootDir, "identity.json"); }
get reposDir() { return join(this.rootDir, "repos"); }
get receiptsDir() { return join(this.rootDir, "receipts"); }
get journalDir() { return join(this.rootDir, "journal"); }
get auditLogPath() { return join(this.rootDir, "audit.log"); }
repoPath(repoId) {
if (!VAULT_SRC_RE.test(repoId))
fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault repo path");
return join(this.reposDir, `${repoId}.json`);
}
recoveryReceiptPath(repoId) {
if (!VAULT_SRC_RE.test(repoId))
fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault receipt path");
return join(this.receiptsDir, `${repoId}.recovery_receipt.json`);
}
// ── permission audit ──
/** Audit every keystore path that exists; findings are recorded in `audit.log` and returned. */
auditPermissions() {
const findings = [];
const check = (path, dir) => {
if (!existsSync(path) && !isSymlink(path))
return;
findings.push(...auditPath(path, dir));
};
check(this.rootDir, true);
check(this.identityPath, false);
check(this.auditLogPath, false);
for (const dir of [this.reposDir, this.journalDir]) {
check(dir, true);
if (existsSync(dir)) {
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
if (entry.endsWith(".lock"))
continue;
check(p, statSafe(p)?.isDirectory() ?? false);
}
}
}
for (const f of findings)
this.audit("permission_finding", undefined, { ...f });
return findings;
}
// ── audit log ──
/** Append one JSON line to `audit.log` (0600, created no-follow). */
audit(event, repoId, details) {
const entry = { at: formatVaultTimestamp(this.now()), event, ...(repoId ? { repo_id: repoId } : {}), ...(details ? { details } : {}) };
mkdir0700(this.rootDir);
if (isSymlink(this.auditLogPath)) {
fail("VAULT_KEYSTORE_SYMLINK", "refusing to append through a symlinked audit.log", "appending vault audit log");
}
const fd = openSync(this.auditLogPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | O_NOFOLLOW, FILE_MODE);
try {
writeSync(fd, `${JSON.stringify(entry)}\n`);
fsyncSync(fd);
}
finally {
closeSync(fd);
}
}
/** Read the audit log (newest last). */
readAuditLog() {
const text = readFileNoFollow(this.auditLogPath);
if (!text)
return [];
return text.split("\n").filter((l) => l.length > 0).map((l) => JSON.parse(l));
}
// ── identity ──
readIdentity() {
const text = readFileNoFollow(this.identityPath);
if (!text)
return null;
const parsed = JSON.parse(text);
if (parsed.version !== 1 || typeof parsed.signing_pubkey !== "string" || typeof parsed.encryption_pubkey !== "string") {
fail("VAULT_KEYSTORE_CORRUPT", "identity.json is not a version-1 vault identity", "reading vault identity");
}
return parsed;
}
/** Create the identity if absent; never overwrites an existing one. */
ensureIdentity() {
const existing = this.readIdentity();
if (existing)
return existing;
const signing = generateSigningKeypair();
const encryption = generateEncryptionKeypair();
const identity = {
version: 1,
signing_seed_hex: bytesToHex(signing.seed),
signing_pubkey: toBase64url(signing.public_key),
signing_fingerprint: vkFingerprint(signing.public_key),
encryption_private_key_hex: bytesToHex(encryption.private_key),
encryption_pubkey: toBase64url(encryption.public_key),
encryption_fingerprint: ekFingerprint(encryption.public_key),
created_at: formatVaultTimestamp(this.now()),
};
if (existsSync(this.identityPath)) {
fail("VAULT_KEYSTORE_CONFLICT", "identity.json appeared concurrently; refusing to overwrite", "creating vault identity");
}
writeFileAtomic0600(this.identityPath, JSON.stringify(identity, null, 2));
this.audit("identity_created", undefined, { signing_fingerprint: identity.signing_fingerprint, encryption_fingerprint: identity.encryption_fingerprint });
return identity;
}
/**
* Record (idempotently) which principal this identity enrolled as. A
* DIFFERENT already-recorded principal is refused — a deliberate migration
* deletes/replaces the identity, it never silently rebinds.
*/
bindIdentityPrincipal(principalId) {
const identity = this.requireIdentity();
if (identity.enrolled_principal_id === principalId)
return;
if (identity.enrolled_principal_id) {
fail("VAULT_IDENTITY_PROFILE_MISMATCH", `this keystore identity enrolled as principal ${identity.enrolled_principal_id}, not ${principalId} — refusing cross-profile reuse`, "binding vault identity", { enrolled_principal_id: identity.enrolled_principal_id, principal_id: principalId });
}
writeFileAtomic0600(this.identityPath, JSON.stringify({ ...identity, enrolled_principal_id: principalId }, null, 2));
this.audit("identity_principal_bound", undefined, { principal_id: principalId });
}
/** The signing keypair, or `null` when the signing seed is lost (read-only principal). */
signingKeypair(identity = this.requireIdentity()) {
if (!identity.signing_seed_hex)
return null;
const kp = generateSigningKeypair(hexToBytes(identity.signing_seed_hex));
if (vkFingerprint(kp.public_key) !== identity.signing_fingerprint) {
fail("VAULT_KEYSTORE_CORRUPT", "identity signing seed does not derive the recorded fingerprint", "loading vault signing key");
}
return kp;
}
/** The encryption keypair, or `null` when the X25519 private key is lost. */
encryptionKeypair(identity = this.requireIdentity()) {
if (!identity.encryption_private_key_hex)
return null;
const kp = generateEncryptionKeypair(hexToBytes(identity.encryption_private_key_hex));
if (ekFingerprint(kp.public_key) !== identity.encryption_fingerprint) {
fail("VAULT_KEYSTORE_CORRUPT", "identity encryption key does not derive the recorded fingerprint", "loading vault encryption key");
}
return kp;
}
requireIdentity() {
const id = this.readIdentity();
if (!id)
fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "loading vault identity", { statement: VAULT_TERMINAL_LOSS_STATEMENT });
return id;
}
// ── repo files ──
readRepo(repoId) {
const text = readFileNoFollow(this.repoPath(repoId));
if (!text)
return null;
const parsed = JSON.parse(text);
if (parsed.version !== 1 || parsed.repo_id !== repoId || typeof parsed.k_repo_hex !== "string") {
fail("VAULT_KEYSTORE_CORRUPT", `repos/${repoId}.json is not a version-1 vault repo file`, "reading vault repo file", { repo_id: repoId });
}
return parsed;
}
listRepoIds() {
if (!existsSync(this.reposDir))
return [];
return readdirSync(this.reposDir).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5)).filter((id) => VAULT_SRC_RE.test(id)).sort();
}
/**
* vault-offline-clone-resolve (design D1/D2/D6, task 1.1): the
* keystore's project→repo lookup that lets {@link resolveVaultAddress}
* (`vault-address.ts`) answer an id-form address WITHOUT the network
* `findVaultByProject` round trip. D2's completeness argument: decrypting
* anything requires exactly this repo file (`k_repo_hex`), so "this
* keystore holds a matching file" is exactly the set of clones that can
* complete regardless of how resolution went — the network call was only
* ever confirming a fact this machine already held.
*
* `orgId`, when supplied, is a HARD equality filter, never a hint: a
* `project_id` match under a different `org_id` is a miss (D1, "mismatched
* org → treat as miss, never guess") — an org transfer or an id collision
* must never resolve to the wrong vault silently. Passing `undefined`
* accepts any org for that project id.
*
* Multiple repo files can legitimately name the same `project_id` — most
* commonly a re-vaulted project that left its old file behind under its
* old `repo_id` on THIS machine. D3: the most-recently-modified file (by
* filesystem mtime — D6 rules out a maintained on-disk index for v1) wins;
* a wrong pick is indistinguishable from an ordinary stale pin and heals
* through the same {@link recoverStaleVaultPin} first-use-refusal path.
*
* Cheap by construction (D6): one directory listing plus one JSON read per
* candidate — no maintained project→repo index. NEVER throws: a corrupt,
* unreadable, or concurrently-deleted repo file is skipped exactly as a
* cache miss, because a project-id scan has no single `repo_id` to blame a
* throw on (unlike {@link readRepo}'s single-id contract, where throwing on
* corruption is the right, attributable failure). Returns `null` on no
* match, an empty keystore, or a not-yet-created `repos/` directory.
*/
findRepoByProject(projectId, orgId) {
let best = null;
for (const repoId of this.listRepoIds()) {
let repo;
try {
repo = this.readRepo(repoId);
}
catch {
continue; // corrupt/unreadable file — skip silently, this is a scan, not a targeted read
}
if (!repo || repo.project_id !== projectId)
continue;
if (orgId !== undefined && repo.org_id !== orgId)
continue;
let mtimeMs;
try {
mtimeMs = statSync(this.repoPath(repoId)).mtimeMs;
}
catch {
continue; // vanished between listing and stat — skip
}
if (!best || mtimeMs > best.mtimeMs)
best = { repo, mtimeMs };
}
return best?.repo ?? null;
}
/** Save (create or replace) a repo file under the per-repo lock. */
saveRepo(repo) {
const full = { version: 1, ...repo, updated_at: formatVaultTimestamp(this.now()) };
this.withRepoLock(repo.repo_id, () => {
writeFileAtomic0600(this.repoPath(repo.repo_id), JSON.stringify(full, null, 2));
});
this.audit("repo_saved", repo.repo_id, { provenance: repo.provenance, genesis_sha256: repo.genesis_sha256 });
return full;
}
/** Update the dual pins / last ref transaction without touching key material. */
updateRepo(repoId, patch) {
return this.withRepoLock(repoId, () => {
const existing = this.readRepo(repoId);
if (!existing)
fail("VAULT_REPO_STATE_MISSING", `no repo file for ${repoId}`, "updating vault repo file", { repo_id: repoId });
const full = { ...existing, ...patch, updated_at: formatVaultTimestamp(this.now()) };
writeFileAtomic0600(this.repoPath(repoId), JSON.stringify(full, null, 2));
return full;
});
}
/**
* vault-object-host-predial (design D1/D4, task 1.2): record that
* `origins` (already-normalized `scheme://host` strings) were observed
* serving THIS repo's objects — the transport's write-through-on-change
* hook. Most-recently-observed wins ties over anything already recorded;
* deduped; capped at {@link VAULT_OBJECT_STORE_ORIGINS_CAP}. A true
* no-op (no lock taken, no write, `updated_at` untouched) when the
* resulting set is IDENTICAL — in order — to what is already on disk, so
* the steady state (every session re-observes the same one or two
* origins) costs nothing. Best-effort by contract: a missing repo file
* (this call racing a not-yet-`saveRepo`'d creation) or any read/write
* failure is swallowed — this is a latency hint, never load-bearing, and
* must never surface into a caller's own object-read path.
*/
recordObjectStoreOrigins(repoId, origins) {
if (origins.length === 0)
return;
try {
const existing = this.readRepo(repoId);
if (!existing)
return;
const current = existing.object_store_origins ?? [];
const next = [];
for (const o of origins)
if (!next.includes(o))
next.push(o);
for (const o of current) {
if (next.length >= VAULT_OBJECT_STORE_ORIGINS_CAP)
break;
if (!next.includes(o))
next.push(o);
}
const capped = next.slice(0, VAULT_OBJECT_STORE_ORIGINS_CAP);
if (capped.length === current.length && capped.every((o, i) => o === current[i]))
return;
this.updateRepo(repoId, { object_store_origins: capped });
}
catch {
/* best-effort learning only — never surfaces into an object read */
}
}
// ── local immutable-object cache (vault-client-round-trips design D3) ──
//
// `<rootDir>/<repo_id>/objects/` — beside the repo file above, NOT inside
// it: genesis bytes, admitted head bytes, and carrier (ref_state /
// retention_roots) ciphertext this principal has already fetched and
// verified once. Holds ONLY ciphertext and signed public objects — never
// decrypted plaintext, key material, or key envelopes. This class stores
// and retrieves bytes alongside the sha256 they were verified against; it
// does not itself re-verify anything — every caller (`Vault`)
// re-checks a cache hit against the SAME pin/receipt it would check
// network bytes against before trusting it, so a stale or tampered entry
// can only ever be discarded and refetched, never silently trusted. A
// missing or corrupt cache file reads back as `null` (a plain cache miss),
// never a thrown error — the cache is a pure accelerator, and its absence
// degrades to exactly today's network-fetch behavior.
objectsRepoDir(repoId) {
if (!VAULT_SRC_RE.test(repoId))
fail("VAULT_BAD_REPO_ID", `not a src_ id: ${repoId}`, "resolving vault object cache path");
return join(this.rootDir, repoId, "objects");
}
objectsGenesisPath(repoId) { return join(this.objectsRepoDir(repoId), "genesis.json"); }
objectsHeadsDir(repoId) { return join(this.objectsRepoDir(repoId), "heads"); }
objectsCarriersDir(repoId) { return join(this.objectsRepoDir(repoId), "carriers"); }
objectsHeadPath(repoId, generation) { return join(this.objectsHeadsDir(repoId), `${generation}.json`); }
objectsCarrierPath(repoId, objectId) { return join(this.objectsCarriersDir(repoId), `${objectId}.json`); }
readCachedFile(path) {
const text = readFileNoFollow(path);
if (!text)
return null;
try {
const parsed = JSON.parse(text);
return typeof parsed === "object" && parsed !== null ? parsed : null;
}
catch {
return null;
}
}
writeCachedFile(path, data) {
writeFileAtomic0600(path, JSON.stringify(data));
}
decodeCachedBytes(data) {
if (!data || typeof data.sha256 !== "string" || typeof data.bytes_base64 !== "string")
return null;
try {
return { sha256: data.sha256, bytes: new Uint8Array(Buffer.from(data.bytes_base64, "base64")) };
}
catch {
return null;
}
}
/** Genesis is one small object per vault, immutable forever — cached and never evicted. */
readCachedGenesis(repoId) {
return this.decodeCachedBytes(this.readCachedFile(this.objectsGenesisPath(repoId)));
}
writeCachedGenesis(repoId, sha256, bytes) {
this.writeCachedFile(this.objectsGenesisPath(repoId), { sha256, bytes_base64: Buffer.from(bytes).toString("base64") });
}
readCachedHead(repoId, generation) {
return this.decodeCachedBytes(this.readCachedFile(this.objectsHeadPath(repoId, generation)));
}
/** Writes the head, then evicts anything more than {@link VAULT_OBJECT_CACHE_WINDOW} generations behind it. */
writeCachedHead(repoId, generation, sha256, bytes) {
this.writeCachedFile(this.objectsHeadPath(repoId, generation), { sha256, bytes_base64: Buffer.from(bytes).toString("base64") });
this.evictObjectCache(repoId, generation);
}
readCachedCarrier(repoId, objectId) {
const data = this.readCachedFile(this.objectsCarrierPath(repoId, objectId));
const decoded = this.decodeCachedBytes(data);
if (!decoded || typeof data?.generation !== "string")
return null;
return { ...decoded, generation: data.generation };
}
/** Writes the carrier (keyed by `object_id`, tagged with the generation that referenced it), then sweeps the window. */
writeCachedCarrier(repoId, objectId, generation, sha256, bytes) {
this.writeCachedFile(this.objectsCarrierPath(repoId, objectId), { sha256, bytes_base64: Buffer.from(bytes).toString("base64"), generation });
this.evictObjectCache(repoId, generation);
}
/**
* Drop heads and carriers more than {@link VAULT_OBJECT_CACHE_WINDOW}
* generations behind `latestGeneration` — genesis is exempt (kept
* forever). Runs inline on every cache write; also callable directly
* (`sweepObjectCache`, wired into `repos gc`) as a periodic backstop that
* catches any orphan a crashed write might have left behind. Best-effort:
* an unlink failure (e.g. already gone) is silently ignored, matching the
* cache's own "a miss just refetches" posture.
*/
evictObjectCache(repoId, latestGeneration) {
const cutoff = genToBigInt(latestGeneration) - BigInt(VAULT_OBJECT_CACHE_WINDOW - 1);
const pruneDir = (dir, generationOf) => {
if (!existsSync(dir))
return;
for (const entry of readdirSync(dir)) {
if (!entry.endsWith(".json"))
continue;
const gen = generationOf(entry);
if (gen === null || genToBigInt(gen) < cutoff) {
try {
unlinkSync(join(dir, entry));
}
catch { /* already gone — fine */ }
}
}
};
pruneDir(this.objectsHeadsDir(repoId), (entry) => {
const gen = entry.slice(0, -".json".length);
return /^[0-9a-f]{16}$/.test(gen) ? gen : null;
});
pruneDir(this.objectsCarriersDir(repoId), (entry) => {
const data = this.readCachedFile(join(this.objectsCarriersDir(repoId), entry));
return typeof data?.generation === "string" ? data.generation : null;
});
}
/** The `repos gc`-time sweep (task 4.2): re-applies the SAME eviction window as an inline write would, for every repo this keystore holds. Safe to call on a repo with no cache directory at all (a no-op). */
sweepObjectCache(repoId) {
const repo = this.readRepo(repoId);
const newest = repo?.materialized_pin?.generation ?? repo?.head_pin?.generation ?? null;
if (newest)
this.evictObjectCache(repoId, newest);
}
/**
* Record a COMMITTED epoch rotation (D194, rev 42): advances the local
* "current" pointer (`epoch` + `k_repo_hex`) to the new epoch's key AND
* appends it to `epoch_keys` (never overwriting a prior entry — every key
* this principal has ever held stays locally available for historical
* decrypt / the D195 prior-key-inequality check on the NEXT rotation).
* Audited distinctly from an ordinary `updateRepo` patch.
*/
recordEpochRotation(repoId, input) {
return this.withRepoLock(repoId, () => {
const existing = this.readRepo(repoId);
if (!existing)
fail("VAULT_REPO_STATE_MISSING", `no repo file for ${repoId}`, "recording vault epoch rotation", { repo_id: repoId });
const epochKeys = { ...(existing.epoch_keys ?? {}), [existing.epoch]: existing.k_repo_hex, [input.new_epoch]: input.new_k_repo_hex };
const full = { ...existing, epoch: input.new_epoch, k_repo_hex: input.new_k_repo_hex, epoch_keys: epochKeys, updated_at: formatVaultTimestamp(this.now()) };
writeFileAtomic0600(this.repoPath(repoId), JSON.stringify(full, null, 2));
this.audit("epoch_rotation_recorded", repoId, { new_epoch: input.new_epoch, known_epoch_count: Object.keys(epochKeys).length });
return full;
});
}
// ── recovery receipts ──
saveRecoveryReceipt(receipt) {
// Not a secret — but written with the same discipline so a partially written
// receipt is never mistaken for a complete one.
writeFileAtomic0600(this.recoveryReceiptPath(receipt.repo_id), JSON.stringify(receipt, null, 2));
this.audit("recovery_receipt_stored", receipt.repo_id, { genesis_sha256: receipt.genesis_sha256 });
}
readRecoveryReceipt(repoId) {
const text = readFileNoFollow(this.recoveryReceiptPath(repoId));
return text ? JSON.parse(text) : null;
}
// ── per-repo lock ──
/**
* Run `fn` holding the per-repo lock (`repos/<repo_id>.lock/`, mkdir-atomic).
* A lock older than `staleAfterMs` whose recorded pid is dead is reclaimed
* (audited). Re-entrant for the same process+repo.
*/
withRepoLock(repoId, fn, options = {}) {
const lockDir = `${this.repoPath(repoId)}.lock`;
if (this.held.has(lockDir))
return fn();
const staleAfterMs = options.staleAfterMs ?? 10 * 60 * 1000;
const timeoutMs = options.timeoutMs ?? 30 * 1000;
const deadline = Date.now() + timeoutMs;
mkdir0700(this.reposDir);
for (;;) {
try {
mkdirSync(lockDir, { mode: DIR_MODE });
break;
}
catch (e) {
if (e.code !== "EEXIST")
throw e;
if (this.reclaimStaleLock(lockDir, repoId, staleAfterMs))
continue;
if (Date.now() >= deadline) {
fail("VAULT_KEYSTORE_LOCKED", `another process holds the lock for ${repoId}`, "acquiring vault repo lock", { repo_id: repoId, lock: lockDir });
}
const until = Date.now() + 25;
while (Date.now() < until) { /* spin */ }
}
}
try {
writeFileAtomic0600(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, at: formatVaultTimestamp(this.now()) }));
}
catch {
/* owner record is advisory */
}
this.held.add(lockDir);
this.audit("lock_acquired", repoId);
try {
return fn();
}
finally {
this.held.delete(lockDir);
try {
safeRemoveLock(lockDir);
}
catch { /* best-effort */ }
this.audit("lock_released", repoId);
}
}
held = new Set();
reclaimStaleLock(lockDir, repoId, staleAfterMs) {
let ownerPid;
let age = Number.POSITIVE_INFINITY;
try {
const st = statSync(lockDir);
age = Date.now() - st.mtimeMs;
const owner = readFileNoFollow(join(lockDir, "owner.json"));
if (owner)
ownerPid = JSON.parse(owner).pid;
}
catch {
/* unreadable owner record → rely on age */
}
const ownerDead = ownerPid !== undefined && ownerPid !== process.pid && !pidAlive(ownerPid);
if (age > staleAfterMs || ownerDead) {
try {
safeRemoveLock(lockDir);
this.audit("lock_stale_reclaimed", repoId, { owner_pid: ownerPid ?? null, age_ms: Math.round(age) });
return true;
}
catch {
return false;
}
}
return false;
}
// ── §5.1 transitions ──
/**
* Derive the partial-loss transition for one vault. `expectedGenesisSha256`
* (from a freshly listed genesis, 5.4) turns a pin mismatch into `stale_pin`.
*/
assess(repoId, expectedGenesisSha256) {
const identity = this.readIdentity();
const repo = this.readRepo(repoId);
let state;
if (!identity && !repo) {
state = { state: "unrecoverable", code: "VAULT_UNRECOVERABLE", statement: VAULT_TERMINAL_LOSS_STATEMENT, doctor_text: VAULT_TERMINAL_LOSS_DOCTOR_TEXT };
}
else if (!identity && repo) {
// K_repo survives but no key can sign or open anything new: the history
// stays READABLE (K_repo decrypts) but nothing can be published.
state = { state: "read_only", repo, reason: "signing_key_lost" };
}
else if (identity && !repo) {
state = identity.encryption_private_key_hex
? { state: "repo_state_lost_identity_intact", next_action: "restore_from_envelope" }
: { state: "unrecoverable", code: "VAULT_UNRECOVERABLE", statement: VAULT_TERMINAL_LOSS_STATEMENT, doctor_text: VAULT_TERMINAL_LOSS_DOCTOR_TEXT };
}
else if (identity && repo && !identity.signing_seed_hex) {
state = { state: "read_only", repo, reason: "signing_key_lost" };
}
else if (expectedGenesisSha256 !== undefined && repo && repo.genesis_sha256 !== expectedGenesisSha256) {
state = { state: "stale_pin", repo, next_action: "reverify_from_genesis" };
}
else {
state = { state: "ready", repo: repo };
}
this.audit("transition_assessed", repoId, { state: state.state });
return state;
}
/**
* §5.1 "repo file lost + identity intact → restore K_repo from own envelope".
* The genesis is checked (signature + key bindings against the stored
* envelope + the recovery receipt/pin when one survives) BEFORE the envelope
* is opened; the restored file is marked `restored_from_envelope`.
*/
async restoreRepoFromEnvelope(input) {
const identity = this.readIdentity();
if (!identity)
fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "restoring vault repo state", { statement: VAULT_TERMINAL_LOSS_STATEMENT });
const recipient = this.encryptionKeypair(identity);
if (!recipient)
fail("VAULT_UNRECOVERABLE", VAULT_TERMINAL_LOSS_DOCTOR_TEXT, "restoring vault repo state", { statement: VAULT_TERMINAL_LOSS_STATEMENT });
const { genesis, envelope } = input;
if (!verifyVaultObject(genesis, genesis.creator_signing_pubkey)) {
fail("VAULT_SIGNATURE_INVALID", "vault_genesis signature does not verify", "restoring vault repo state", { repo_id: genesis.repo_id });
}
// The creator's OWN genesis envelope is bound to genesis byte-for-byte
// (hash + size + recipient). Any OTHER recipient's envelope (a member
// wrapped post-genesis — vault-agent-envelopes D4's cold open) is
// not in genesis at all: for it, check genesis's own structural bindings
// plus the envelope's addressing (this vault, this epoch, written under
// the registered writer key); `openKeyEnvelope` then verifies the
// writer's signature over it before anything is opened.
const creatorEk = ekFingerprint(fromBase64url(genesis.creator_encryption_pubkey, "creator_encryption_pubkey"));
const isCreatorEnvelope = envelope.recipient_fingerprint === creatorEk;
const bindings = isCreatorEnvelope ? checkGenesisKeyBindings(genesis, envelope) : checkGenesisKeyBindings(genesis);
// The key that must have signed this envelope: the genesis writer, or —
// rev 47, writers plural — the admitted writer `created_by` names.
let envelopeSignerPubkey = genesis.creator_signing_pubkey;
if (!isCreatorEnvelope) {
if (envelope.repo_id !== genesis.repo_id)
bindings.push("stored_envelope_repo_id");
if (envelope.epoch !== genesis.epoch)
bindings.push("stored_envelope_epoch");
if (envelope.created_by !== genesis.writer_key_id) {
const wrapper = (input.admitted_writers ?? []).find((w) => w.writer_key_id === envelope.created_by);
if (!wrapper)
bindings.push("stored_envelope_created_by");
else
envelopeSignerPubkey = wrapper.signing_pubkey;
}
}
if (bindings.length > 0) {
fail("VAULT_CREATION_CONFLICT", "genesis key bindings do not hold for the stored envelope", "restoring vault repo state", { repo_id: genesis.repo_id, problems: bindings });
}
const genesisSha = storedBytesSha256(genesis);
// First-open pin (agent-envelopes D4): a genesis this keystore already
// pinned for this repo_id — in a surviving repo file or the cached genesis
// — must be the SAME genesis; a different one is a substituted vault.
const priorRepo = this.readRepo(genesis.repo_id);
const priorPin = priorRepo?.genesis_sha256 ?? this.readCachedGenesis(genesis.repo_id)?.sha256 ?? null;
if (priorPin && priorPin !== genesisSha) {
fail("VAULT_CREATION_CONFLICT", `the admitted genesis (${genesisSha}) is not the one this keystore pinned (${priorPin}); refusing a substituted vault`, "restoring vault repo state", { repo_id: genesis.repo_id, admitted: genesisSha, pinned: priorPin });
}
const continuity = priorPin ? "pinned" : "first_seen";
const receipt = input.recovery_receipt ?? this.readRecoveryReceipt(genesis.repo_id);
let trust = input.allocation_attested ? "platform_attested" : "unauthenticated_salvage";
if (receipt) {
const problems = checkRecoveryReceipt(receipt, genesis);
if (problems.length > 0) {
fail("VAULT_CREATION_CONFLICT", "recovery receipt does not pin this genesis (substituted vault?)", "restoring vault repo state", { repo_id: genesis.repo_id, problems });
}
trust = "receipt";
}
if (envelope.recipient_fingerprint !== identity.encryption_fingerprint) {
fail("VAULT_ENVELOPE_NOT_FOR_RECIPIENT", "the stored envelope is addressed to another principal", "restoring vault repo state", { recipient_fingerprint: envelope.recipient_fingerprint });
}
const kRepo = await openKeyEnvelope({ envelope, recipient, signer_public_key: envelopeSignerPubkey });
const repo = this.saveRepo({
repo_id: genesis.repo_id,
org_id: genesis.org_id,
project_id: genesis.project_id,
k_repo_hex: bytesToHex(kRepo),
epoch: envelope.epoch,
genesis_sha256: genesisSha,
head_pin: null,
last_ref_transaction: null,
provenance: "restored_from_envelope",
});
this.audit("repo_restored_from_envelope", genesis.repo_id, { trust, continuity });
return { repo, trust, continuity, independently_verified: trust === "receipt" };
}
/** Identity fingerprints for display/doctor (never key material). */
identitySummary() {
const id = this.readIdentity();
if (!id)
return null;
if (!VAULT_VK_RE.test(id.signing_fingerprint) || !VAULT_EK_RE.test(id.encryption_fingerprint)) {
fail("VAULT_KEYSTORE_CORRUPT", "identity fingerprints are malformed", "reading vault identity");
}
return {
signing_fingerprint: id.signing_fingerprint,
encryption_fingerprint: id.encryption_fingerprint,
signing_key_present: Boolean(id.signing_seed_hex),
encryption_key_present: Boolean(id.encryption_private_key_hex),
};
}
}
function isSymlink(path) {
try {
return lstatSync(path).isSymbolicLink();
}
catch {
return false;
}
}
function statSafe(path) {
try {
return lstatSync(path);
}
catch {
return null;
}
}
function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
}
catch (e) {
return e.code === "EPERM";
}
}
function safeRemoveLock(lockDir) {
const owner = join(lockDir, "owner.json");
// rmdir needs an empty directory; the owner record is the only file ever written there.
if (existsSync(owner))
unlinkSync(owner);
rmdirSync(lockDir);
}
//# sourceMappingURL=vault-keystore.js.map