diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index 180ab585..9d478271 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -117,6 +117,22 @@ interface Held { expiresAt: number; } +/** + * A live high-risk unlock, held in memory only. + * + * `tag` is the partition key tag, from which the partition file's encryption + * key is derived — so it is key material, not an identifier. Python writes + * this value into `agent_high_risk_unlock.json`, which means that for the + * life of a Python unlock session the partition is decryptable from disk with + * no factor at all, defeating the second factor the partition exists to + * require. Keeping it here, in the process that already holds unlocked parent + * seeds, is the same trust boundary with none of the disk exposure. + */ +interface HighRiskSession { + tag: string; + expiresAt: number; +} + export interface AgentStatusProfile { fingerprint: string; expires_at: number; @@ -132,10 +148,18 @@ const OWNER_OPS = new Set([ "lock", "status", "shutdown", + // High-risk unlock is strictly more privileged than an ordinary unlock: + // it is the second factor for the entry kinds deemed to need one, so a + // scoped token must never be able to reach it. + "high-risk-unlock", + "high-risk-lock", + "high-risk-status", + "high-risk-tag", ]); export class AgentDaemon { private held = new Map(); + private highRisk = new Map(); private tokens = new Map(); private audits = new Map(); private auditQueue: Promise = Promise.resolve(); @@ -465,6 +489,9 @@ export class AgentDaemon { /** Drop everything derived from a profile's seed. */ private forget(fingerprint: string): void { this.held.delete(fingerprint); + // A high-risk unlock is scoped to an unlocked profile; locking the vault + // must not leave the stronger grant standing. + this.highRisk.delete(fingerprint); // Tokens are only meaningful while the profile is unlocked; leaving them // resident would let them spring back to life on the next unlock. for (const [id, token] of this.tokens) { @@ -479,6 +506,28 @@ export class AgentDaemon { for (const [fp, held] of this.held) { if (held.expiresAt <= now) this.forget(fp); } + // High-risk sessions expire on their own clock too: they are typically + // much shorter than the seed's TTL, and outliving it would silently + // extend the second factor. + for (const [fp, session] of this.highRisk) { + if (session.expiresAt <= now) this.highRisk.delete(fp); + } + } + + /** + * The live partition key tag for a profile, or "" when not unlocked. + * + * Never logged and never included in an audit record: it is the key to the + * partition, so recording it would put it on disk by another route. + */ + private highRiskTag(fingerprint: string): string { + const session = this.highRisk.get(fingerprint); + if (!session) return ""; + if (session.expiresAt <= Date.now() / 1000) { + this.highRisk.delete(fingerprint); + return ""; + } + return session.tag; } private async handle(msg: Record): Promise> { @@ -704,6 +753,65 @@ export class AgentDaemon { } return { ok: true, expires_at: expiresAt }; } + case "high-risk-unlock": { + // The factor is verified by the CALLER (which reads the envelope and + // unwraps it); the agent receives the resulting tag. That keeps the + // KDF work out of the daemon and means a wrong factor never reaches + // it — but it also means this op hands over partition access, which + // is why it is owner-gated. + const fingerprint = String(msg["fingerprint"] ?? ""); + const tag = String(msg["tag"] ?? ""); + if (!FINGERPRINT_RE.test(fingerprint)) { + return { ok: false, error: "fingerprint must be 16 uppercase hex characters" }; + } + if (!/^[0-9a-f]{64}$/.test(tag)) { + return { ok: false, error: "tag must be a 64-character hex digest" }; + } + // A high-risk grant on a locked profile would outlive nothing and + // protect nothing: the vault index it applies to is not open. + if (!this.held.has(fingerprint)) { + return { ok: false, error: "profile not unlocked" }; + } + const ttl = positiveIntField(msg["ttl"], 300); + if (ttl === null) return { ok: false, error: "ttl must be a positive integer" }; + const expiresAt = Math.floor(Date.now() / 1000 + ttl); + this.highRisk.set(fingerprint, { tag, expiresAt }); + try { + // Records THAT it was unlocked and for how long — never the tag. + await this.auditLog(fingerprint, "high_risk_unlocked", { ttl }); + } catch (e) { + this.highRisk.delete(fingerprint); + throw e; + } + return { ok: true, expires_at: expiresAt }; + } + case "high-risk-lock": { + const fingerprint = String(msg["fingerprint"] ?? ""); + const had = this.highRisk.delete(fingerprint); + if (had) await this.auditLog(fingerprint, "high_risk_locked", {}); + return { ok: true, locked: had }; + } + case "high-risk-status": { + const fingerprint = String(msg["fingerprint"] ?? ""); + const session = this.highRisk.get(fingerprint); + const live = this.highRiskTag(fingerprint) !== ""; + return { + ok: true, + fingerprint, + unlocked: live, + // The expiry is safe to disclose; the tag is not, and is not here. + expires_at: live && session ? session.expiresAt : null, + }; + } + case "high-risk-tag": { + // Owner-gated, and the only way the tag leaves the daemon. Returned + // to the owner's own CLI so it can open the partition; a scoped token + // cannot reach this op at all. + const fingerprint = String(msg["fingerprint"] ?? ""); + const tag = this.highRiskTag(fingerprint); + if (!tag) return { ok: false, error: "high-risk partition is locked" }; + return { ok: true, tag }; + } case "owner-mnemonic": { // Owner-capability gated: this hands back the parent seed so the // owner's CLI can operate the vault locally. It is deliberately NOT @@ -869,6 +977,50 @@ export class AgentClient { } } + /** + * Hand a verified partition key tag to the agent for a bounded window. + * + * The tag is key material. It goes over the 0600 unix socket to a process + * that already holds this profile's parent seed, and is never written to + * disk by either side. + */ + async highRiskUnlock( + fingerprint: string, + tag: string, + ttl: number, + ): Promise { + const r = await this.ownerRequest({ op: "high-risk-unlock", fingerprint, tag, ttl }); + if (!r["ok"]) throw new Error(String(r["error"])); + return Number(r["expires_at"] ?? 0); + } + + async highRiskLock(fingerprint: string): Promise { + const r = await this.ownerRequest({ op: "high-risk-lock", fingerprint }); + if (!r["ok"]) throw new Error(String(r["error"])); + return Boolean(r["locked"]); + } + + async highRiskStatus( + fingerprint: string, + ): Promise<{ unlocked: boolean; expires_at: number | null }> { + const r = await this.ownerRequest({ op: "high-risk-status", fingerprint }); + if (!r["ok"]) throw new Error(String(r["error"])); + return { + unlocked: Boolean(r["unlocked"]), + expires_at: r["expires_at"] === null ? null : Number(r["expires_at"]), + }; + } + + /** The live tag, or null when the partition is locked. */ + async highRiskTag(fingerprint: string): Promise { + try { + const r = await this.ownerRequest({ op: "high-risk-tag", fingerprint }); + return r["ok"] ? String(r["tag"]) : null; + } catch { + return null; + } + } + async lock(fingerprint?: string): Promise { const r = await this.ownerRequest({ op: "lock", ...(fingerprint && { fingerprint }) }); if (!r["ok"]) throw new Error(String(r["error"])); diff --git a/js/packages/cli/src/api/routes.ts b/js/packages/cli/src/api/routes.ts index 04222c6b..a808188d 100644 --- a/js/packages/cli/src/api/routes.ts +++ b/js/packages/cli/src/api/routes.ts @@ -38,6 +38,7 @@ import { passwordPolicyFromRecord, removeLink, restoreEntry, + isPartitionStub, buildSemanticRecords, searchSemanticRecords, semanticManifest, @@ -56,6 +57,7 @@ import { openVault, saveVault, saveVaultHoldingLock, withVaultLock, atomicWrite import { entryMetadata, refFor, resolveEntry } from "../refs.js"; import { materializeSecret } from "../secrets.js"; import { createIndexBackup } from "../backups.js"; +import { factorConfigured, tagForFactor, readPartition, partitionPath } from "../highRisk.js"; import { HttpError, type ApiRequest, type ApiResponse, type ApiServer } from "./server.js"; /** @@ -67,7 +69,6 @@ import { HttpError, type ApiRequest, type ApiResponse, type ApiServer } from "./ * an integrator hunting for a spelling mistake that is not there. */ export const UNPORTED_PREFIXES: Array<{ prefix: string; feature: string }> = [ - { prefix: "/api/v1/high-risk", feature: "high-risk partitions" }, { prefix: "/api/v1/agent/job-profiles", feature: "agent job profiles" }, { prefix: "/api/v1/agent/recovery", feature: "agent recovery split" }, ]; @@ -91,9 +92,52 @@ export interface ApiContext { notifications: Array<{ level: string; message: string }>; /** Requests the process to exit; wired by the CLI command. */ requestShutdown: () => void; + /** + * Live high-risk partition key tag, or null when locked. + * + * In memory only — this value is the partition's encryption key, so writing + * it anywhere would let a reader of that file open the partition without + * the second factor. + */ + highRiskTag: string | null; + /** Unix seconds at which the high-risk unlock lapses. */ + highRiskExpiresAt: number; now: () => number; } +/** + * Replace a high-risk stub with its real record, or refuse. + * + * A stub carries only kind/label/archived, so deriving from one produces the + * wrong secret rather than an error — which is why this refuses loudly + * instead of falling through. + */ +async function hydratePartitionedFor( + ctx: ApiContext, + id: string, + entry: Record, +): Promise> { + if (!isPartitionStub(entry)) return entry; + const live = ctx.highRiskTag !== null && ctx.highRiskExpiresAt > ctx.now() / 1000; + if (!live) { + ctx.highRiskTag = null; + throw new HttpError( + 423, + `entry ${id} is in the high-risk partition, which is locked. ` + + `POST /api/v1/high-risk/unlock with the factor first.`, + ); + } + const partition = await readPartition(profileDir(ctx), ctx.highRiskTag!); + const full = partition[String(entry["partition_ref"] ?? id)]; + if (!full) { + throw new HttpError( + 409, + `entry ${id} points at a high-risk record that is not in the partition file`, + ); + } + return full; +} + function requireUnlocked(ctx: ApiContext): string { if (!ctx.mnemonic) throw new HttpError(423, "Vault is locked"); return ctx.mnemonic; @@ -240,8 +284,9 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { const mnemonic = requireUnlocked(ctx); const vault = await readVault(ctx); const id = entryIdOf(req); - const entry = vault.index.entries[id]; - if (!entry) throw new HttpError(404, "Not found"); + const stub = vault.index.entries[id]; + if (!stub) throw new HttpError(404, "Not found"); + const entry = await hydratePartitionedFor(ctx, id, stub as unknown as Record); const timestamp = optInt(req.query.get("at") ?? undefined, "at"); const config = await loadConfig(profileDir(ctx), mnemonic); const secret = materializeSecret(vault.index, id, entry as Entry, mnemonic, { @@ -817,6 +862,68 @@ export function registerRoutes(server: ApiServer, ctx: ApiContext): void { }; }); + // -------------------------------------------------------------- high risk + + /** + * The high-risk unlock lives on the ApiContext, in memory, for exactly the + * reason the CLI keeps it in the agent: the partition key tag IS the + * partition's encryption key, so persisting it would let anything that can + * read the directory open the partition without the second factor. + */ + server.route("GET", "/api/v1/high-risk/status", async () => { + return { + json: { + fingerprint: ctx.fingerprint, + configured: factorConfigured(ctx.appDir.root), + unlocked: ctx.highRiskTag !== null && ctx.highRiskExpiresAt > ctx.now() / 1000, + expires_at: ctx.highRiskTag !== null ? ctx.highRiskExpiresAt : null, + partition_exists: existsSync(partitionPath(profileDir(ctx))), + }, + }; + }); + + server.route( + "POST", + "/api/v1/high-risk/unlock", + async (req) => { + // Password-gated on top of the bearer token, like every other route + // that reaches secrets — and then the FACTOR on top of that. The whole + // point of the partition is that the master password alone is not + // enough for these kinds. + await requirePassword(ctx, req); + const factor = req.headers["x-seedpass-high-risk-factor"]; + if (!factor) { + throw new HttpError( + 401, + "the high-risk factor is required in the X-SeedPass-High-Risk-Factor header", + ); + } + if (!factorConfigured(ctx.appDir.root)) { + throw new HttpError(409, "high_risk_factor_not_configured"); + } + const ttl = optInt(bodyObject(req)["ttl"], "ttl") ?? 300; + let tag: string; + try { + tag = await tagForFactor(ctx.appDir.root, factor); + } catch { + // One reason for every factor failure: a caller must not be able to + // tell "wrong factor" from "corrupt envelope" by probing. + throw new HttpError(401, "high_risk_factor_invalid"); + } + ctx.highRiskTag = tag; + ctx.highRiskExpiresAt = Math.floor(ctx.now() / 1000 + ttl); + return { json: { status: "unlocked", expires_at: ctx.highRiskExpiresAt } }; + }, + { requiresPassword: true }, + ); + + server.route("POST", "/api/v1/high-risk/lock", async () => { + const was = ctx.highRiskTag !== null; + ctx.highRiskTag = null; + ctx.highRiskExpiresAt = 0; + return { json: { status: "ok", locked: was } }; + }); + // --------------------------------------------------------------- semantic const semanticDir = (): string => join(profileDir(ctx), "semantic_index"); diff --git a/js/packages/cli/src/api/serve.ts b/js/packages/cli/src/api/serve.ts index 28a40ab6..3145d601 100644 --- a/js/packages/cli/src/api/serve.ts +++ b/js/packages/cli/src/api/serve.ts @@ -42,6 +42,9 @@ export function buildContext(options: { fingerprint: options.fingerprint, mnemonic: options.mnemonic, notifications: [], + // Locked until the factor is supplied; never restored from disk. + highRiskTag: null, + highRiskExpiresAt: 0, now: options.now ?? (() => Date.now()), requestShutdown: options.requestShutdown ?? (() => {}), verifyPassword: async (password: string) => { @@ -90,10 +93,11 @@ export async function serveApi(options: ServeApiOptions): Promise { const bound = await server.listen(); const stop = async (): Promise => { - // Drop the seed before releasing the port, not after: the window between - // the two is a window where the process is still holding an unlocked seed - // with no way to say so. + // Both the seed and the high-risk tag go before the port closes, not + // after: the gap between them would be a window where the process still + // holds key material with no way to say so. ctx.mnemonic = null; + ctx.highRiskTag = null; await server.close(); }; diff --git a/js/packages/cli/src/approvals.ts b/js/packages/cli/src/approvals.ts new file mode 100644 index 00000000..9620245c --- /dev/null +++ b/js/packages/cli/src/approvals.ts @@ -0,0 +1,218 @@ +/** + * Approval gates — parity with src/seedpass/core/agent_approval.py. + * + * An approval is a one-shot (or N-shot) authorization for a named dangerous + * action, issued out of band and consumed at the moment the action runs. It + * is the "someone deliberately said yes to this" record that sits between a + * policy saying an action NEEDS approval and the action happening. + * + * Approvals are stored at the app level rather than per profile, matching + * Python, and hold no secret material: an approval id is a capability to + * perform an action that the caller must ALSO be otherwise authorized for. + * It is not a credential on its own, which is why the store is readable + * without unlocking anything. + */ + +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWrite, withVaultLock } from "./vaultFile.js"; +import { utf8 } from "@seedpass/core"; + +export const APPROVAL_STORE_VERSION = 1; +export const APPROVAL_STORE_FILENAME = "agent_approvals.json"; + +/** Actions an approval may cover. Anything else is refused at issue time. */ +export const VALID_APPROVAL_ACTIONS = [ + "export", + "reveal_parent_seed", + "private_key_retrieval", +] as const; + +export type ApprovalAction = (typeof VALID_APPROVAL_ACTIONS)[number]; + +export interface ApprovalRecord { + id: string; + action: string; + resource: string; + issued_by: string; + created_at_utc: string; + expires_at_utc: string; + uses_remaining: number; + revoked_at_utc: string | null; +} + +interface ApprovalStore { + version: number; + approvals: ApprovalRecord[]; +} + +function emptyStore(): ApprovalStore { + return { version: APPROVAL_STORE_VERSION, approvals: [] }; +} + +export function approvalStorePath(appRoot: string): string { + return join(appRoot, APPROVAL_STORE_FILENAME); +} + +async function loadStore(path: string): Promise { + if (!existsSync(path)) return emptyStore(); + try { + const data = JSON.parse(await readFile(path, "utf8")); + if (typeof data !== "object" || data === null || !Array.isArray(data.approvals)) { + return emptyStore(); + } + return data as ApprovalStore; + } catch { + // A corrupt store reads as empty, which fails CLOSED: every approval + // check then denies rather than accidentally allowing. + return emptyStore(); + } +} + +async function saveStore(path: string, store: ApprovalStore): Promise { + await atomicWrite(path, utf8(JSON.stringify(store, null, 2))); +} + +/** Read-modify-write under a lock, so two agents cannot both spend one use. */ +async function mutateStore( + path: string, + fn: (store: ApprovalStore) => T | Promise, +): Promise { + return withVaultLock(path, async () => { + const store = await loadStore(path); + const result = await fn(store); + await saveStore(path, store); + return result; + }); +} + +export function isValidApprovalAction(action: string): action is ApprovalAction { + return (VALID_APPROVAL_ACTIONS as readonly string[]).includes(action); +} + +export async function issueApproval( + appRoot: string, + options: { + action: string; + ttlSeconds: number; + uses: number; + resource?: string; + issuedBy?: string; + now?: number; + }, +): Promise { + if (!isValidApprovalAction(options.action)) { + throw new Error( + `unknown approval action '${options.action}' ` + + `(expected one of ${VALID_APPROVAL_ACTIONS.join(", ")})`, + ); + } + if (!Number.isInteger(options.ttlSeconds) || options.ttlSeconds < 1) { + throw new Error("ttl must be a positive integer number of seconds"); + } + if (!Number.isInteger(options.uses) || options.uses < 1) { + throw new Error("uses must be a positive integer"); + } + const now = options.now ?? Date.now(); + const record: ApprovalRecord = { + // 18 random bytes, like Python's token_urlsafe(18). The id is the whole + // reference to this approval, so it must not be guessable. + id: randomBytes(18).toString("base64url"), + action: options.action, + resource: options.resource ?? "*", + issued_by: options.issuedBy ?? "manual", + created_at_utc: new Date(now).toISOString(), + expires_at_utc: new Date(now + options.ttlSeconds * 1000).toISOString(), + uses_remaining: options.uses, + revoked_at_utc: null, + }; + await mutateStore(approvalStorePath(appRoot), (store) => { + store.approvals.push(record); + }); + return record; +} + +export async function listApprovals( + appRoot: string, + options: { includeRevoked?: boolean } = {}, +): Promise { + const approvals = (await loadStore(approvalStorePath(appRoot))).approvals; + if (options.includeRevoked) return approvals; + return approvals.filter((a) => !a.revoked_at_utc); +} + +export async function revokeApproval( + appRoot: string, + approvalId: string, + options: { now?: number } = {}, +): Promise { + const stamp = new Date(options.now ?? Date.now()).toISOString(); + return mutateStore(approvalStorePath(appRoot), (store) => { + for (const record of store.approvals) { + if (record.id === approvalId && !record.revoked_at_utc) { + record.revoked_at_utc = stamp; + return true; + } + } + return false; + }); +} + +/** Does this policy require an approval for `action`? */ +export function approvalRequired(policy: Record, action: string): boolean { + const approvals = policy["approvals"]; + if (typeof approvals !== "object" || approvals === null) return false; + const required = (approvals as Record)["require_for"]; + if (!Array.isArray(required)) return false; + const wanted = action.trim().toLowerCase(); + return required.some((v) => String(v).trim().toLowerCase() === wanted); +} + +export interface ConsumeResult { + ok: boolean; + /** Machine-readable outcome, matching Python's strings exactly. */ + reason: string; +} + +/** + * Spend one use of an approval. + * + * Every failure mode gets its own reason, and the whole check runs under the + * store lock so two callers cannot both spend the last use. The use is + * decremented and persisted BEFORE the caller acts on the result — an + * approval that was consumed but whose action then failed is the safe + * direction to err. + */ +export async function consumeApproval( + appRoot: string, + options: { approvalId: string; action: string; resource?: string; now?: number }, +): Promise { + const now = options.now ?? Date.now(); + const resource = options.resource ?? "*"; + return mutateStore(approvalStorePath(appRoot), (store) => { + for (const record of store.approvals) { + if (record.id !== options.approvalId) continue; + if (record.revoked_at_utc) return { ok: false, reason: "approval_revoked" }; + if (String(record.action).toLowerCase() !== options.action.toLowerCase()) { + return { ok: false, reason: "approval_action_mismatch" }; + } + const recordResource = String(record.resource ?? "*"); + if (recordResource !== "*" && recordResource !== resource) { + return { ok: false, reason: "approval_resource_mismatch" }; + } + const expires = record.expires_at_utc; + if (!expires || Date.parse(expires) <= now) { + return { ok: false, reason: "approval_expired" }; + } + const uses = Number(record.uses_remaining ?? 0); + if (!Number.isFinite(uses) || uses <= 0) { + return { ok: false, reason: "approval_exhausted" }; + } + record.uses_remaining = uses - 1; + return { ok: true, reason: "approval_consumed" }; + } + return { ok: false, reason: "approval_not_found" }; + }); +} diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index e2d98738..56f0d79f 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -88,6 +88,32 @@ export function capabilities(): Record { locked_status: 423, unported_status: 501, }, + high_risk: { + commands: [ + "agent high-risk factor-set/status/unlock/lock/migrate", + "POST /api/v1/high-risk/unlock|lock", + "GET /api/v1/high-risk/status", + ], + kinds: ["ssh", "pgp", "seed", "nostr", "managed_account"], + // The partition file and key envelope are byte-compatible with Python, + // so a partition made by either implementation opens in the other. + interop: "file formats shared with the Python implementation", + // Where this port DIVERGES, and why. Python records the unlock in + // agent_high_risk_unlock.json including the partition key tag, from + // which the partition's encryption key is derived — so during any + // Python session the partition is readable from disk with no factor. + // Here the tag lives only in the agent's memory (or the API process's). + session_storage: "in-memory only; the partition key tag never reaches disk", + factor_env: "SEEDPASS_HIGH_RISK_FACTOR", + api_factor_header: "X-SeedPass-High-Risk-Factor", + }, + approvals: { + commands: ["agent approval issue/list/revoke"], + actions: ["export", "reveal_parent_seed", "private_key_retrieval"], + // An approval is not a credential: it authorizes an action for a caller + // that must ALSO be otherwise authorized. + semantics: "one-shot (or N-use) authorization, consumed at action time", + }, audit: { commands: ["agent audit-verify", "agent audit-tail"], chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", @@ -105,10 +131,9 @@ export function capabilities(): Record { // Python-derived state, recomputed on load. Carried through verbatim by // the TS port so a Python profile round-trips, but not computed here. "index0/atlas (preserved verbatim on read/write, never recomputed)", - // Deliberate: agent approval gates, high-risk partitions, job profiles - // and recovery split. The session agent's scoped tokens cover the - // automation cases; these add a second authorization model. - "agent approval gates, high-risk partitions, job profiles, recovery split", + // Deliberate: agent job profiles and recovery split. The session + // agent's scoped tokens cover the automation cases these serve. + "agent job profiles and recovery split", // Deliberate: the TS interactive mode follows Python's legacy v1 menu // tree. See docs/tui_v2_cutover_decision.md. "Python's v2/v3 Textual TUIs (interactive mode follows the legacy v1 menus)", diff --git a/js/packages/cli/src/highRisk.ts b/js/packages/cli/src/highRisk.ts new file mode 100644 index 00000000..7eed6e5f --- /dev/null +++ b/js/packages/cli/src/highRisk.ts @@ -0,0 +1,122 @@ +/** + * High-risk partition: factor configuration and profile-level access. + * + * The on-disk formats (partition file, key envelope) live in core and are + * byte-compatible with Python, so a partition created by either + * implementation opens in the other. What lives here is the local plumbing: + * where the envelope sits, and how an unlock session is held. + * + * ONE DELIBERATE DIVERGENCE, and it is the whole reason this module exists + * rather than a straight port. Python records an unlock session in + * `agent_high_risk_unlock.json`, and that record contains the + * `partition_key_tag` — from which the partition file's encryption key is + * derived. So for the life of any Python unlock session, the high-risk + * partition is decryptable from disk alone, with no factor, by anything that + * can read the app directory. That voids the guarantee the partition exists + * to provide: these are precisely the secrets deemed to need a SECOND factor. + * + * The TypeScript port holds the unlocked tag in the session agent's memory + * instead, next to the parent seeds it already holds, with the same TTL + * discipline. Nothing derived from the factor is ever written to disk. This + * changes no file format and no interop — only where the live session lives. + */ + +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + buildPartitionEnvelope, + decryptPartition, + encryptPartition, + generatePartitionKey, + partitionKeyTag, + unwrapPartitionKey, + HighRiskError, + PARTITION_FILENAME, + utf8, +} from "@seedpass/core"; +import { atomicWrite } from "./vaultFile.js"; + +export const ENVELOPE_FILENAME = "agent_high_risk_partition.key.enc.json"; + +export function envelopePath(appRoot: string): string { + return join(appRoot, ENVELOPE_FILENAME); +} + +export function partitionPath(profileDir: string): string { + return join(profileDir, PARTITION_FILENAME); +} + +/** Has a high-risk factor been configured for this installation? */ +export function factorConfigured(appRoot: string): boolean { + return existsSync(envelopePath(appRoot)); +} + +/** + * Set (or replace) the high-risk factor. + * + * Replacing the factor generates a NEW partition key, which makes any + * existing partition file undecryptable — so callers must re-encrypt an + * existing partition under the new key, or refuse. `setFactor` deliberately + * does not do that silently: losing a partition is losing secrets. + */ +export async function setFactor(appRoot: string, factor: string): Promise { + if (!factor) throw new Error("the high-risk factor cannot be empty"); + const partitionKey = generatePartitionKey(); + const envelope = await buildPartitionEnvelope(partitionKey, factor); + await atomicWrite(envelopePath(appRoot), utf8(JSON.stringify(envelope, null, 2))); + return partitionKeyTag(partitionKey); +} + +async function readEnvelope(appRoot: string): Promise> { + const path = envelopePath(appRoot); + if (!existsSync(path)) { + throw new HighRiskError("high_risk_partition_not_configured"); + } + try { + return JSON.parse(await readFile(path, "utf8")) as Record; + } catch { + throw new HighRiskError("invalid_partition_envelope"); + } +} + +/** + * Turn the factor into the partition key tag. + * + * The returned value is key material, not an identifier — see + * `partitionKeyTag` in core. Hold it in memory and drop it; never persist it. + */ +export async function tagForFactor(appRoot: string, factor: string): Promise { + return partitionKeyTag(await unwrapPartitionKey(await readEnvelope(appRoot), factor)); +} + +/** Is this the configured factor? Answers without disclosing the tag. */ +export async function verifyFactor(appRoot: string, factor: string): Promise { + try { + await tagForFactor(appRoot, factor); + return true; + } catch { + return false; + } +} + +/** Read a profile's partition, or `{}` when it has none. */ +export async function readPartition( + profileDir: string, + tag: string, +): Promise>> { + const path = partitionPath(profileDir); + if (!existsSync(path)) return {}; + return decryptPartition(new Uint8Array(await readFile(path)), tag); +} + +/** Write a profile's partition at 0600. */ +export async function writePartition( + profileDir: string, + tag: string, + entries: Record>, +): Promise { + const path = partitionPath(profileDir); + await atomicWrite(path, await encryptPartition(entries, tag)); + return path; +} diff --git a/js/packages/cli/src/index.ts b/js/packages/cli/src/index.ts index 837fba32..4a2b0080 100644 --- a/js/packages/cli/src/index.ts +++ b/js/packages/cli/src/index.ts @@ -10,3 +10,21 @@ export { AgentDaemon, AgentClient, agentSocketPath } from "./agent.js"; export { ApiServer, HttpError, resolveBind } from "./api/server.js"; export { registerRoutes, UNPORTED_PREFIXES, type ApiContext } from "./api/routes.js"; export { buildContext, serveApi } from "./api/serve.js"; +export { + setFactor, + tagForFactor, + verifyFactor, + factorConfigured, + readPartition, + writePartition, + partitionPath, +} from "./highRisk.js"; +export { + issueApproval, + listApprovals, + revokeApproval, + consumeApproval, + approvalRequired, + VALID_APPROVAL_ACTIONS, + type ApprovalRecord, +} from "./approvals.js"; diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index 891074cf..014ca00a 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -26,6 +26,10 @@ import { parseBackupWrapper, exportBackup, findDerivationCollisions, + HIGH_RISK_KINDS, + isPartitionStub, + partitionStub, + type Entry, buildSemanticRecords, searchSemanticRecords, semanticManifest, @@ -170,6 +174,20 @@ import { import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js"; import { AuditLog } from "./audit.js"; import { createIndexBackup } from "./backups.js"; +import { + factorConfigured, + setFactor, + tagForFactor, + readPartition, + writePartition, + partitionPath, +} from "./highRisk.js"; +import { + issueApproval, + listApprovals, + revokeApproval, + VALID_APPROVAL_ACTIONS, +} from "./approvals.js"; import { runTui } from "./tui/app.js"; export interface ProgramIo { @@ -404,9 +422,44 @@ async function openReadAccess(opts: GlobalOpts): Promise { // Resolved on first use, not up front: reading the config costs another // BIP-39 seed derivation, and `entry list` and friends never need a policy. let basePolicy: PasswordPolicy | undefined; + + /** + * Replace a high-risk stub with its real record. + * + * A stub carries only kind/label/archived, so materializing a secret from + * one would derive from the wrong fields — or, for an imported secret, + * from nothing at all. The full record lives in the partition, and reaching + * it needs the second factor to be live in the agent. + */ + const hydrate = async (id: string, entry: Entry): Promise => { + const record = entry as unknown as Record; + if (!isPartitionStub(record)) return entry; + const client = new AgentClient(agentSocketPath(app.root)); + const fp = await currentFingerprint(app, opts); + const tag = await client.highRiskTag(fp); + if (!tag) { + throw new Error( + `entry ${id} is in the high-risk partition and the partition is ` + + `locked. Run 'agent high-risk unlock' with SEEDPASS_HIGH_RISK_FACTOR set.`, + ); + } + const partition = await readPartition(app.profileDir(fp), tag); + const full = partition[String(record["partition_ref"] ?? id)]; + if (!full) { + // The stub says a record exists and the partition disagrees. Say so + // rather than deriving from the stub and handing back a wrong secret. + throw new Error( + `entry ${id} points at a high-risk record that is not in the ` + + `partition file; the partition may be from a different factor.`, + ); + } + return full as unknown as Entry; + }; + const localSecret = async (id: string, timestamp?: number) => { - const entry = vault.index.entries[id]; - if (!entry) throw new Error(`no entry ${id}`); + const stub = vault.index.entries[id]; + if (!stub) throw new Error(`no entry ${id}`); + const entry = await hydrate(id, stub); basePolicy ??= await basePolicyForOptions(opts, vault.mnemonic); return materializeSecret(vault.index, id, entry, vault.mnemonic, { ...(timestamp !== undefined && { timestamp }), @@ -1813,6 +1866,205 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ); }); + // ------------------------------------------------- high-risk partition + + /** + * The factor never comes from argv — it is the second factor for exactly + * the secrets deemed to need one, and argv is in the process list and the + * shell history. + */ + function requireFactor(): string { + const factor = process.env["SEEDPASS_HIGH_RISK_FACTOR"]; + if (!factor) { + throw new Error( + "set SEEDPASS_HIGH_RISK_FACTOR in the environment (never pass the " + + "high-risk factor on the command line)", + ); + } + return factor; + } + + const highRisk = agent + .command("high-risk") + .description("second-factor partition for ssh/pgp/seed/nostr entries"); + + highRisk + .command("factor-set") + .description("configure the high-risk factor (reads SEEDPASS_HIGH_RISK_FACTOR)") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + if (factorConfigured(app.root)) { + // Replacing the factor mints a NEW partition key, which would leave + // any existing partition file undecryptable. Losing a partition is + // losing secrets, so this refuses rather than doing it quietly. + throw new Error( + "a high-risk factor is already configured. Replacing it generates a " + + "new partition key and would make any existing partition " + + "permanently unreadable; migrate the entries back out first.", + ); + } + await setFactor(app.root, requireFactor()); + io.out(JSON.stringify({ status: "ok", configured: true })); + }); + + highRisk + .command("status") + .description("whether the factor is configured and the partition unlocked") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + let session = { unlocked: false, expires_at: null as number | null }; + try { + session = await client.highRiskStatus(fp); + } catch { + // No agent running means nothing is unlocked, which is the honest + // answer rather than an error. + } + io.out( + JSON.stringify({ + fingerprint: fp, + configured: factorConfigured(app.root), + unlocked: session.unlocked, + expires_at: session.expires_at, + partition_exists: existsSync(partitionPath(app.profileDir(fp))), + }), + ); + }); + + highRisk + .command("unlock") + .description("unlock the partition for a bounded window (reads SEEDPASS_HIGH_RISK_FACTOR)") + .option("--ttl ", "unlock lifetime", "300") + .action(async (o: { ttl: string }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + // Unwrapping happens HERE, not in the daemon: a wrong factor never + // reaches the process holding seeds, and the KDF cost stays out of it. + const tag = await tagForFactor(app.root, requireFactor()); + const client = new AgentClient(agentSocketPath(app.root)); + const expiresAt = await client.highRiskUnlock( + fp, + tag, + parseIntOption(o.ttl, "--ttl", { min: 1 }), + ); + io.out(JSON.stringify({ status: "unlocked", fingerprint: fp, expires_at: expiresAt })); + }); + + highRisk + .command("lock") + .description("drop the high-risk unlock immediately") + .action(async () => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const client = new AgentClient(agentSocketPath(app.root)); + io.out(JSON.stringify({ status: "ok", locked: await client.highRiskLock(fp) })); + }); + + highRisk + .command("migrate") + .description("move high-risk entries out of the vault index into the partition") + .option( + "--kind ", + `entry kinds to move (default: ${HIGH_RISK_KINDS.join(", ")})`, + ) + .action(async (o: { kind?: string[] }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const fp = await currentFingerprint(app, opts); + const tag = await tagForFactor(app.root, requireFactor()); + const kinds = new Set(o.kind ?? [...HIGH_RISK_KINDS]); + const profileDir = app.profileDir(fp); + + const moved: string[] = []; + await mutateVault(opts, async (vault) => { + const partition = await readPartition(profileDir, tag); + for (const [id, raw] of Object.entries(vault.index.entries)) { + const entry = raw as unknown as Record; + if (isPartitionStub(entry)) continue; // already moved + const kind = String(entry["kind"] ?? entry["type"] ?? "").toLowerCase(); + if (!kinds.has(kind)) continue; + partition[id] = entry; + (vault.index.entries as unknown as Record)[id] = + partitionStub(id, entry, kind, Date.now() / 1000); + moved.push(id); + } + // Write the partition BEFORE the index. If the order were reversed a + // crash between them would leave stubs pointing at entries that were + // never stored — the vault would have lost the secrets outright. + if (moved.length > 0) await writePartition(profileDir, tag, partition); + }); + io.out( + JSON.stringify({ + moved_count: moved.length, + moved_indexes: moved.sort((a, b) => Number(a) - Number(b)), + partition_file: partitionPath(profileDir), + }), + ); + }); + + // ------------------------------------------------------- approval gates + + const approval = agent + .command("approval") + .description("one-shot authorizations for dangerous actions"); + + approval + .command("issue") + .description("issue an approval for a named action") + .requiredOption("--action ", VALID_APPROVAL_ACTIONS.join("|")) + .option("--resource ", "restrict to one resource", "*") + .option("--ttl ", "lifetime", "300") + .option("--uses ", "how many times it may be consumed", "1") + .option("--issued-by ", "who authorized it, for the record", "manual") + .action(async (o: { + action: string; resource: string; ttl: string; uses: string; issuedBy: string; + }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const record = await issueApproval(app.root, { + action: o.action, + resource: o.resource, + issuedBy: o.issuedBy, + ttlSeconds: parseIntOption(o.ttl, "--ttl", { min: 1 }), + uses: parseIntOption(o.uses, "--uses", { min: 1 }), + }); + io.out(JSON.stringify(record, null, 2)); + }); + + approval + .command("list") + .description("list approvals") + .option("--include-revoked", "also show revoked ones") + .action(async (o: { includeRevoked?: boolean }) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + io.out( + JSON.stringify( + await listApprovals(app.root, { + ...(o.includeRevoked !== undefined && { includeRevoked: o.includeRevoked }), + }), + null, + 2, + ), + ); + }); + + approval + .command("revoke ") + .description("revoke an approval immediately") + .action(async (approvalId: string) => { + const opts = program.opts() as GlobalOpts; + const app = new AppDir(resolveAppDir(opts.appDir)); + const revoked = await revokeApproval(app.root, approvalId); + if (!revoked) throw new Error(`no active approval ${approvalId}`); + io.out(JSON.stringify({ status: "ok", revoked: approvalId })); + }); + const util = program.command("util").description("utility commands"); util diff --git a/js/packages/cli/test/api.test.ts b/js/packages/cli/test/api.test.ts index 6025f057..d02edd81 100644 --- a/js/packages/cli/test/api.test.ts +++ b/js/packages/cli/test/api.test.ts @@ -26,6 +26,7 @@ import { addTotpDeterministic, type VaultIndex, } from "@seedpass/core"; +import { setFactor, tagForFactor } from "../src/highRisk.js"; import { ApiServer, registerRoutes, @@ -538,10 +539,67 @@ describe("semantic index", () => { }); }); +describe("high-risk partition over the API", () => { + it("reports the factor as unconfigured before it is set", async () => { + const res = await call("GET", "/api/v1/high-risk/status"); + expect(res.status).toBe(200); + expect(res.json.configured).toBe(false); + expect(res.json.unlocked).toBe(false); + }); + + it("needs the master password AND the factor to unlock", async () => { + await setFactor(appDir, "api-second-factor"); + + // Bearer token alone: no. + expect((await call("POST", "/api/v1/high-risk/unlock")).status).toBe(401); + // Password but no factor: still no. The whole point of the partition is + // that the master password is not enough for these kinds. + const noFactor = await call("POST", "/api/v1/high-risk/unlock", { password: PASSWORD }); + expect(noFactor.status).toBe(401); + expect(noFactor.json.detail).toContain("High-Risk-Factor"); + // Wrong factor: one reason, so probing cannot distinguish failures. + const wrong = await call("POST", "/api/v1/high-risk/unlock", { + password: PASSWORD, + headers: { "x-seedpass-high-risk-factor": "not-it" }, + }); + expect(wrong.status).toBe(401); + expect(wrong.json.detail).toBe("high_risk_factor_invalid"); + + const ok = await call("POST", "/api/v1/high-risk/unlock", { + password: PASSWORD, + headers: { "x-seedpass-high-risk-factor": "api-second-factor" }, + body: { ttl: 60 }, + }); + expect(ok.status).toBe(200); + expect(ok.json.status).toBe("unlocked"); + }); + + it("never returns the partition key tag over the wire", async () => { + const tag = await tagForFactor(appDir, "api-second-factor"); + // The tag IS the partition's encryption key. No response may carry it. + for (const [method, path] of [ + ["GET", "/api/v1/high-risk/status"], + ["POST", "/api/v1/high-risk/lock"], + ] as const) { + const res = await call(method, path); + expect(res.text).not.toContain(tag); + } + }); + + it("locks on request", async () => { + await call("POST", "/api/v1/high-risk/unlock", { + password: PASSWORD, + headers: { "x-seedpass-high-risk-factor": "api-second-factor" }, + }); + expect((await call("GET", "/api/v1/high-risk/status")).json.unlocked).toBe(true); + expect((await call("POST", "/api/v1/high-risk/lock")).json.locked).toBe(true); + expect((await call("GET", "/api/v1/high-risk/status")).json.unlocked).toBe(false); + }); +}); + describe("the unported surface answers honestly", () => { it("returns 501 with the reason, not 404", async () => { for (const [method, path, feature] of [ - ["GET", "/api/v1/high-risk/status", "high-risk partitions"], ["GET", "/api/v1/agent/job-profiles", "agent job profiles"], ["POST", "/api/v1/agent/recovery/split", "agent recovery split"], ] as const) { diff --git a/js/packages/cli/test/highRisk.test.ts b/js/packages/cli/test/highRisk.test.ts new file mode 100644 index 00000000..b67ce2ec --- /dev/null +++ b/js/packages/cli/test/highRisk.test.ts @@ -0,0 +1,392 @@ +/** + * High-risk partition and approval gates, end to end. + * + * The partition exists so that a handful of entry kinds need a SECOND factor + * beyond the master password. Most of this file is therefore about the ways + * that guarantee could be lost: the tag reaching disk, a locked partition + * still yielding secrets, a stub deriving a plausible-but-wrong value, or a + * scoped token reaching the unlock op. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { mnemonics } from "@seedpass/test-vectors"; +import { + generateFingerprint, + deriveIndexKeyBytes, + encryptV3, + utf8, + addSshKeyEntry, + addPasswordEntry, + partitionKeyTag, + type VaultIndex, +} from "@seedpass/core"; +import { + buildProgram, + AgentDaemon, + AgentClient, + agentSocketPath, + AppDir, + INDEX_FILENAME, + type ProgramIo, +} from "../src/index.js"; +import { setFactor, tagForFactor, factorConfigured, partitionPath } from "../src/highRisk.js"; +import { + issueApproval, + listApprovals, + revokeApproval, + consumeApproval, + approvalRequired, +} from "../src/approvals.js"; + +const MNEMONIC = mnemonics["abandon12"]!; +const FINGERPRINT = generateFingerprint(MNEMONIC); +const PASSWORD = "high-risk-test-pw"; +const FACTOR = "the-second-factor"; + +let appDir: string; +let app: AppDir; +let daemon: AgentDaemon; + +async function run( + env: Record, + ...argv: string[] +): Promise<{ stdout: string; stderr: string; error?: unknown }> { + const saved: Record = {}; + for (const [k, v] of Object.entries(env)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const out: string[] = []; + const err: string[] = []; + const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) }; + let error: unknown; + try { + await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]); + } catch (e) { + error = e; + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } + return { stdout: out.join("\n"), stderr: err.join("\n"), error }; +} + +const asOwner = { SEEDPASS_MNEMONIC: MNEMONIC }; +const withFactor = { ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: FACTOR }; +/** `vault unlock` decrypts the parent seed, so it needs the master password. */ +const toUnlock = { ...asOwner, SEEDPASS_PASSWORD: PASSWORD }; + +beforeAll(async () => { + appDir = await mkdtemp(join(tmpdir(), "seedpass-hr-")); + app = new AppDir(appDir); + process.env["SEEDPASS_AGENT_SOCK"] = join(appDir, "agent.sock"); + daemon = new AgentDaemon(agentSocketPath(appDir), 900, appDir); + await daemon.start(); + + await app.mutateFingerprints((d) => { + d.fingerprints.push(FINGERPRINT); + d.names[FINGERPRINT] = "hr"; + d.last_used = FINGERPRINT; + }); + await mkdir(app.profileDir(FINGERPRINT), { recursive: true }); + await app.writeParentSeed(FINGERPRINT, MNEMONIC, PASSWORD); +}); + +afterAll(async () => { + await daemon.stop(); + delete process.env["SEEDPASS_AGENT_SOCK"]; +}); + +beforeEach(async () => { + // Fresh vault each test: migration is destructive to the index by design. + const index = { schema_version: 4, entries: {} } as VaultIndex; + addPasswordEntry(index, "ordinary.example", 16, {}); + addSshKeyEntry(index, "prod-server", { notes: "deployment key" }); + await writeFile( + join(app.profileDir(FINGERPRINT), INDEX_FILENAME), + await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))), + ); +}); + +describe("the factor", () => { + it("is configured once and refuses to be silently replaced", async () => { + const first = await run(withFactor, "agent", "high-risk", "factor-set"); + expect(JSON.parse(first.stdout).configured).toBe(true); + expect(factorConfigured(appDir)).toBe(true); + + // Replacing it mints a new partition key, which would make an existing + // partition permanently unreadable. Losing a partition is losing secrets. + const second = await run( + { ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: "different" }, + "agent", "high-risk", "factor-set", + ); + expect(String((second.error as Error).message)).toContain("already configured"); + }); + + it("is never read from the command line", async () => { + const r = await run({ ...asOwner, SEEDPASS_HIGH_RISK_FACTOR: undefined }, + "agent", "high-risk", "unlock"); + expect(String((r.error as Error).message)).toContain("SEEDPASS_HIGH_RISK_FACTOR"); + }); + + it("verifies correctly and rejects a wrong one", async () => { + expect(await tagForFactor(appDir, FACTOR)).toMatch(/^[0-9a-f]{64}$/); + await expect(tagForFactor(appDir, "wrong")).rejects.toMatchObject({ + reason: "high_risk_factor_invalid", + }); + }); +}); + +describe("migration and access", () => { + it("moves high-risk entries out of the index and leaves only a stub", async () => { + await run(toUnlock, "vault", "unlock"); + const migrated = await run(withFactor, "agent", "high-risk", "migrate"); + expect(JSON.parse(migrated.stdout).moved_count).toBe(1); + + // The index no longer holds the entry's substance. + const raw = await readFile(join(app.profileDir(FINGERPRINT), INDEX_FILENAME)); + const listed = JSON.parse((await run(asOwner, "entry", "list")).stdout); + const stub = listed.find((r: any) => r.label === "prod-server"); + expect(stub).toBeTruthy(); + expect(JSON.stringify(listed)).not.toContain("deployment key"); + void raw; + + // The partition file exists and is 0600 — it is the whole secret. + const path = partitionPath(app.profileDir(FINGERPRINT)); + expect(existsSync(path)).toBe(true); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); + + it("refuses to reveal a partitioned entry while the partition is locked", async () => { + await run(toUnlock, "vault", "unlock"); + await run(withFactor, "agent", "high-risk", "migrate"); + await run(asOwner, "agent", "high-risk", "lock"); + + const r = await run(asOwner, "entry", "reveal", "prod-server"); + expect(String((r.error as Error).message)).toContain("high-risk partition"); + expect(r.stdout).toBe(""); + }); + + it("reveals it again once unlocked, and matches the pre-migration secret", async () => { + await run(toUnlock, "vault", "unlock"); + // Capture the secret BEFORE migrating, so this proves the round trip + // rather than merely that something came back. + const before = (await run(asOwner, "entry", "reveal", "prod-server")).stdout; + expect(before.length).toBeGreaterThan(0); + + await run(withFactor, "agent", "high-risk", "migrate"); + await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "60"); + const after = (await run(asOwner, "entry", "reveal", "prod-server")).stdout; + expect(after).toBe(before); + }); + + it("leaves ordinary entries alone", async () => { + await run(toUnlock, "vault", "unlock"); + const before = (await run(asOwner, "entry", "reveal", "ordinary.example")).stdout; + await run(withFactor, "agent", "high-risk", "migrate"); + // No unlock: a password entry must still work with the master password. + const after = (await run(asOwner, "entry", "reveal", "ordinary.example")).stdout; + expect(after).toBe(before); + }); + + it("is idempotent — a second migration moves nothing", async () => { + await run(toUnlock, "vault", "unlock"); + await run(withFactor, "agent", "high-risk", "migrate"); + const again = await run(withFactor, "agent", "high-risk", "migrate"); + expect(JSON.parse(again.stdout).moved_count).toBe(0); + }); +}); + +describe("the unlock never reaches disk", () => { + it("writes no file containing the partition key tag", async () => { + await run(toUnlock, "vault", "unlock"); + await run(withFactor, "agent", "high-risk", "migrate"); + await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "300"); + + const tag = await tagForFactor(appDir, FACTOR); + expect(tag).toMatch(/^[0-9a-f]{64}$/); + + // The tag IS the partition's encryption key. Python records it in + // agent_high_risk_unlock.json for the life of a session, which lets + // anything that can read the app directory open the partition with no + // factor at all — voiding the second factor the partition exists for. + // Nothing here may contain it. + for (const name of await readdir(appDir)) { + const path = join(appDir, name); + if (!(await stat(path)).isFile()) continue; + const contents = await readFile(path, "utf8").catch(() => ""); + expect(contents).not.toContain(tag); + } + // And specifically: no session file exists at all. + expect(existsSync(join(appDir, "agent_high_risk_unlock.json"))).toBe(false); + }); + + it("drops the unlock when the vault is locked", async () => { + await run(toUnlock, "vault", "unlock"); + await run(withFactor, "agent", "high-risk", "migrate"); + await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "300"); + + const client = new AgentClient(agentSocketPath(appDir)); + expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(true); + // Locking the vault must not leave the stronger grant standing. + await client.lock(FINGERPRINT); + expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(false); + }); + + it("expires on its own clock", async () => { + await run(toUnlock, "vault", "unlock"); + const client = new AgentClient(agentSocketPath(appDir)); + const tag = await tagForFactor(appDir, FACTOR); + const expiresAt = await client.highRiskUnlock(FINGERPRINT, tag, 1); + expect(expiresAt).toBeGreaterThan(0); + await new Promise((r) => setTimeout(r, 1300)); + expect((await client.highRiskStatus(FINGERPRINT)).unlocked).toBe(false); + expect(await client.highRiskTag(FINGERPRINT)).toBeNull(); + }); + + it("refuses an unlock for a profile whose vault is locked", async () => { + const client = new AgentClient(agentSocketPath(appDir)); + await client.lock(FINGERPRINT); + const tag = await tagForFactor(appDir, FACTOR); + // A high-risk grant over a locked vault protects nothing and would + // outlive the thing it applies to. + await expect(client.highRiskUnlock(FINGERPRINT, tag, 60)).rejects.toThrow(/not unlocked/); + }); + + it("rejects a malformed tag rather than storing it", async () => { + await run(toUnlock, "vault", "unlock"); + const client = new AgentClient(agentSocketPath(appDir)); + for (const bad of ["", "short", "z".repeat(64), "../../etc"]) { + await expect(client.highRiskUnlock(FINGERPRINT, bad, 60)).rejects.toThrow(); + } + }); + + it("keeps the tag out of the audit log", async () => { + await run(toUnlock, "vault", "unlock"); + await run(withFactor, "agent", "high-risk", "unlock", "--ttl", "60"); + const tag = await tagForFactor(appDir, FACTOR); + const audit = await readFile(join(app.profileDir(FINGERPRINT), "audit.log"), "utf8"); + // The audit records THAT the partition was unlocked; recording the tag + // would put the key on disk by another route. + expect(audit).toContain("high_risk_unlocked"); + expect(audit).not.toContain(tag); + }); +}); + +describe("approval gates", () => { + it("issues, lists, consumes and exhausts", async () => { + const issued = JSON.parse( + (await run(asOwner, "agent", "approval", "issue", "--action", "export", "--uses", "2")) + .stdout, + ); + expect(issued.action).toBe("export"); + expect(issued.uses_remaining).toBe(2); + expect(issued.id).toHaveLength(24); // 18 random bytes, base64url + + expect(await listApprovals(appDir)).toHaveLength(1); + + expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({ + ok: true, + reason: "approval_consumed", + }); + expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({ + ok: true, + reason: "approval_consumed", + }); + // Third use: the budget is spent. + expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({ + ok: false, + reason: "approval_exhausted", + }); + }); + + it("distinguishes every way a consume can fail", async () => { + const issued = await issueApproval(appDir, { + action: "export", + resource: "vault-a", + ttlSeconds: 300, + uses: 1, + }); + + expect( + await consumeApproval(appDir, { approvalId: "no-such-id", action: "export" }), + ).toEqual({ ok: false, reason: "approval_not_found" }); + + expect( + await consumeApproval(appDir, { approvalId: issued.id, action: "reveal_parent_seed" }), + ).toEqual({ ok: false, reason: "approval_action_mismatch" }); + + expect( + await consumeApproval(appDir, { + approvalId: issued.id, + action: "export", + resource: "vault-b", + }), + ).toEqual({ ok: false, reason: "approval_resource_mismatch" }); + + // Still unconsumed after all those failures. + expect((await listApprovals(appDir)).find((a) => a.id === issued.id)!.uses_remaining).toBe(1); + }); + + it("refuses an expired approval", async () => { + const issued = await issueApproval(appDir, { + action: "export", + ttlSeconds: 1, + uses: 1, + now: Date.now() - 10_000, + }); + expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({ + ok: false, + reason: "approval_expired", + }); + }); + + it("refuses a revoked approval and hides it from the default listing", async () => { + const issued = await issueApproval(appDir, { action: "export", ttlSeconds: 300, uses: 5 }); + expect(await revokeApproval(appDir, issued.id)).toBe(true); + // Revoking twice is not an error but changes nothing. + expect(await revokeApproval(appDir, issued.id)).toBe(false); + + expect(await consumeApproval(appDir, { approvalId: issued.id, action: "export" })).toEqual({ + ok: false, + reason: "approval_revoked", + }); + expect((await listApprovals(appDir)).map((a) => a.id)).not.toContain(issued.id); + expect((await listApprovals(appDir, { includeRevoked: true })).map((a) => a.id)).toContain( + issued.id, + ); + }); + + it("refuses to issue an approval for an action it does not know", async () => { + // An unknown action would produce an approval that gates nothing while + // looking like it gates something. + await expect( + issueApproval(appDir, { action: "delete_everything", ttlSeconds: 60, uses: 1 }), + ).rejects.toThrow(/unknown approval action/); + }); + + it("reads require_for out of a policy", () => { + const policy = { approvals: { require_for: ["Export", "reveal_parent_seed"] } }; + expect(approvalRequired(policy, "export")).toBe(true); + expect(approvalRequired(policy, "REVEAL_PARENT_SEED")).toBe(true); + expect(approvalRequired(policy, "private_key_retrieval")).toBe(false); + // A malformed policy must not accidentally mean "nothing needs approval" + // in a way that differs from Python — both treat it as no requirement. + expect(approvalRequired({}, "export")).toBe(false); + expect(approvalRequired({ approvals: "yes" }, "export")).toBe(false); + }); + + it("stores approvals at 0600", async () => { + await issueApproval(appDir, { action: "export", ttlSeconds: 60, uses: 1 }); + const mode = (await stat(join(appDir, "agent_approvals.json"))).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 188f425a..311ee9e2 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -87,6 +87,21 @@ export { needsMigration, SchemaMigrationError, } from "./schema/migrations.js"; +export { + PARTITION_FILENAME, + PARTITION_KDF_ITERATIONS, + HIGH_RISK_KINDS, + HighRiskError, + partitionKeyTag, + decryptPartition, + encryptPartition, + buildPartitionEnvelope, + unwrapPartitionKey, + generatePartitionKey, + partitionStub, + isPartitionStub, + type PartitionEnvelope, +} from "./vault/highRiskPartition.js"; export { encodeQr, renderQrText, EC_LEVELS, type QrMatrix, type EcLevel } from "./util/qr.js"; export { buildSemanticRecords, diff --git a/js/packages/core/src/vault/fernet.ts b/js/packages/core/src/vault/fernet.ts index 0e1f3592..ceac3e5f 100644 --- a/js/packages/core/src/vault/fernet.ts +++ b/js/packages/core/src/vault/fernet.ts @@ -6,7 +6,11 @@ * The 32-byte key splits into signing key (first 16) and encryption key * (last 16). TTL checking is not implemented — SeedPass never sets one. * - * Decrypt-only by design: new data is always written as V3 AES-GCM. + * Vault data is always written as V3 AES-GCM, never Fernet. The one exception + * is the high-risk partition and its key envelope: those files are read and + * written by BOTH implementations, and Python's format is Fernet, so writing + * them requires an encryptor. Do not reach for it for anything else — a new + * Fernet payload anywhere in the vault is a bug. */ import { hmac } from "@noble/hashes/hmac.js"; @@ -89,3 +93,60 @@ export async function fernetDecrypt( throw new FernetError("decryption failed"); } } + +async function aesCbcEncrypt( + key: Uint8Array, + iv: Uint8Array, + plaintext: Uint8Array, +): Promise { + const cryptoKey = await globalThis.crypto.subtle.importKey( + "raw", + key as BufferSource, + { name: "AES-CBC" }, + false, + ["encrypt"], + ); + // WebCrypto applies PKCS7 padding itself, which is what Fernet specifies. + const out = await globalThis.crypto.subtle.encrypt( + { name: "AES-CBC", iv: iv as BufferSource }, + cryptoKey, + plaintext as BufferSource, + ); + return new Uint8Array(out); +} + +/** + * Produce a Fernet token, for the two files Python also writes. + * + * `key` is the raw 32 bytes: signing key first 16, encryption key last 16. + * `iv` and `timestamp` exist for tests that need a fixed token; production + * must let both default, since a repeated IV under one key breaks CBC. + */ +export async function fernetEncrypt( + key: Uint8Array, + plaintext: Uint8Array, + options: { iv?: Uint8Array; timestamp?: number } = {}, +): Promise { + if (key.length !== 32) throw new FernetError(`Fernet key must be 32 bytes, got ${key.length}`); + const signingKey = key.slice(0, 16); + const encryptionKey = key.slice(16); + const iv = options.iv ?? globalThis.crypto.getRandomValues(new Uint8Array(16)); + if (iv.length !== 16) throw new FernetError("Fernet IV must be 16 bytes"); + const timestamp = BigInt(options.timestamp ?? Math.floor(Date.now() / 1000)); + + const ciphertext = await aesCbcEncrypt(encryptionKey, iv, plaintext); + const body = new Uint8Array(HEADER_LENGTH + ciphertext.length); + body[0] = VERSION; + // Big-endian 64-bit seconds, as the spec has it. + for (let i = 0; i < 8; i++) { + body[1 + i] = Number((timestamp >> BigInt(8 * (7 - i))) & 0xffn); + } + body.set(iv, 9); + body.set(ciphertext, HEADER_LENGTH); + + const tag = hmac(sha256, signingKey, body); + const token = new Uint8Array(body.length + HMAC_LENGTH); + token.set(body); + token.set(tag, body.length); + return base64url.encode(token); +} diff --git a/js/packages/core/src/vault/highRiskPartition.ts b/js/packages/core/src/vault/highRiskPartition.ts new file mode 100644 index 00000000..8c844e01 --- /dev/null +++ b/js/packages/core/src/vault/highRiskPartition.ts @@ -0,0 +1,229 @@ +/** + * High-risk partition: on-disk format — parity with + * src/seedpass/core/high_risk_partition_store.py and + * src/seedpass/core/agent_secret_isolation.py. + * + * The idea is that some entry kinds (ssh, pgp, seed, nostr, managed_account) + * are worth a SECOND factor beyond the master password. Their full records + * move out of the vault index into a separate encrypted file, and the index + * keeps only a stub naming the partition. Reading one back needs the factor. + * + * This module is the file format only, so a partition written by either + * implementation opens in the other. It deliberately holds no session state: + * see the note on `partitionKeyTag` for why that separation matters. + */ + +import { sha256 } from "@noble/hashes/sha2.js"; +import { pbkdf2 } from "@noble/hashes/pbkdf2.js"; +import { base64, base64url } from "@scure/base"; +import { fernetDecrypt, fernetEncrypt } from "./fernet.js"; +import { bytesToHex, utf8 } from "../util/bytes.js"; + +export const PARTITION_FILENAME = "seedpass_high_risk_entries.json.enc"; +export const PARTITION_SCHEMA_VERSION = 1; +export const PARTITION_ENVELOPE_VERSION = 1; +export const PARTITION_KDF_ITERATIONS = 200_000; + +/** Entry kinds treated as high risk by default, matching Python's policy. */ +export const HIGH_RISK_KINDS = ["ssh", "pgp", "seed", "nostr", "managed_account"] as const; + +export class HighRiskError extends Error { + constructor( + readonly reason: string, + message?: string, + ) { + super(message ?? reason); + this.name = "HighRiskError"; + } +} + +/** + * The identifier for a partition key. + * + * IMPORTANT: this value is not merely an identifier — `partitionFileKey` + * derives the partition's encryption key from it, so anything holding the tag + * can decrypt the partition without the factor. It must never be written to + * disk, logged, or returned over an API. Python stores it in + * `agent_high_risk_unlock.json` during an unlock session, which is why the + * TypeScript port keeps unlock state in the agent's memory instead; see + * docs and `agent.ts`. + */ +export function partitionKeyTag(partitionKey: string): string { + return bytesToHex(sha256(utf8(partitionKey))); +} + +/** The Fernet key for the partition file, derived from the tag. */ +function partitionFileKey(tag: string): Uint8Array { + return sha256(utf8(tag)); +} + +export interface PartitionPayload { + schema_version: number; + partition: string; + updated_at_utc: number; + entries: Record>; +} + +/** Decrypt a partition file's bytes into its entry map. */ +export async function decryptPartition( + blob: Uint8Array, + tag: string, +): Promise>> { + let plaintext: Uint8Array; + try { + plaintext = await fernetDecrypt(partitionFileKey(tag), blob); + } catch (cause) { + // The same reason string Python raises, so callers can branch on it. + throw new HighRiskError("invalid_partition_key_tag", "invalid_partition_key_tag"); + } + let data: unknown; + try { + data = JSON.parse(new TextDecoder().decode(plaintext)); + } catch { + throw new HighRiskError("invalid_partition_payload", "partition payload is not JSON"); + } + if (typeof data !== "object" || data === null) return {}; + const entries = (data as Record)["entries"]; + if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return {}; + const out: Record> = {}; + for (const [k, v] of Object.entries(entries)) { + if (typeof v === "object" && v !== null && !Array.isArray(v)) { + out[String(k)] = v as Record; + } + } + return out; +} + +/** + * Encrypt an entry map as a partition file. + * + * The JSON is serialized with sorted keys and no whitespace, matching + * Python's `json.dumps(..., sort_keys=True, separators=(",", ":"))` — not for + * byte-identical output (the Fernet IV is random) but so the plaintext is + * canonical in both implementations. + */ +export async function encryptPartition( + entries: Record>, + tag: string, + options: { updatedAt?: number } = {}, +): Promise { + const payload: PartitionPayload = { + schema_version: PARTITION_SCHEMA_VERSION, + partition: "high_risk", + updated_at_utc: options.updatedAt ?? Date.now() / 1000, + entries, + }; + // Sorted keys at every level, no whitespace — JSON.stringify's + // replacer-array only sorts the top level, so this is done explicitly. + return utf8(await fernetEncrypt(partitionFileKey(tag), utf8(stableStringify(payload)))); +} + +/** JSON with every object's keys sorted, matching Python's sort_keys=True. */ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + const keys = Object.keys(value as Record).sort(); + const parts = keys.map( + (k) => `${JSON.stringify(k)}:${stableStringify((value as Record)[k])}`, + ); + return `{${parts.join(",")}}`; +} + +// ---------------------------------------------------------------- envelope + +export interface PartitionEnvelope { + version: number; + kdf: string; + iterations: number; + salt_b64: string; + wrapped_partition_key: string; +} + +function wrappingKey(factor: string, salt: Uint8Array, iterations: number): Uint8Array { + // Python derives 32 raw bytes then base64url-encodes them, and hands that + // ASCII to Fernet(), which base64url-DECODES it back. So the effective key + // is the raw 32 bytes; the round trip is a no-op that must not be + // reproduced literally or the key would be the ASCII of the encoding. + return pbkdf2(sha256, utf8(factor), salt, { c: iterations, dkLen: 32 }); +} + +/** Wrap a freshly generated partition key under the factor. */ +export async function buildPartitionEnvelope( + partitionKey: string, + factor: string, + options: { salt?: Uint8Array; iterations?: number } = {}, +): Promise { + const salt = options.salt ?? globalThis.crypto.getRandomValues(new Uint8Array(16)); + const iterations = options.iterations ?? PARTITION_KDF_ITERATIONS; + const wrapped = await fernetEncrypt( + wrappingKey(factor, salt, iterations), + utf8(partitionKey), + ); + return { + version: PARTITION_ENVELOPE_VERSION, + kdf: "pbkdf2-sha256", + iterations, + salt_b64: base64.encode(salt), + wrapped_partition_key: wrapped, + }; +} + +/** Recover the partition key from an envelope, or throw with Python's reason. */ +export async function unwrapPartitionKey( + // Accepts either the typed envelope or a raw parsed JSON object, because + // callers get one from disk and the other from buildPartitionEnvelope. + envelope: PartitionEnvelope | Record, + factor: string, +): Promise { + const fields = envelope as Record; + if (Number(fields["version"] ?? 0) !== PARTITION_ENVELOPE_VERSION) { + throw new HighRiskError("unsupported_partition_envelope_version"); + } + const saltB64 = String(fields["salt_b64"] ?? ""); + const wrapped = String(fields["wrapped_partition_key"] ?? ""); + const iterations = Number(fields["iterations"] ?? PARTITION_KDF_ITERATIONS); + if (!saltB64 || !wrapped || !Number.isFinite(iterations) || iterations < 1) { + throw new HighRiskError("invalid_partition_envelope"); + } + const key = wrappingKey(factor, base64.decode(saltB64), iterations); + try { + return new TextDecoder().decode(await fernetDecrypt(key, wrapped)); + } catch { + throw new HighRiskError("high_risk_factor_invalid"); + } +} + +/** A new random partition key, in the form Python generates (a Fernet key). */ +export function generatePartitionKey(): string { + return base64url.encode(globalThis.crypto.getRandomValues(new Uint8Array(32))); +} + +/** + * The stub left in the vault index when an entry moves to the partition. + * + * Deliberately carries only what a listing needs — kind, label, archived — + * so an index read without the factor discloses that a high-risk entry + * exists, and nothing about it. + */ +export function partitionStub( + id: string, + entry: Record, + kind: string, + now: number, +): Record { + return { + type: kind, + kind, + index: Number(entry["index"] ?? id), + label: String(entry["label"] ?? ""), + archived: Boolean(entry["archived"] ?? false), + partition: "high_risk", + partition_ref: id, + modified_ts: Number(entry["modified_ts"] ?? Math.floor(now)), + }; +} + +/** Is this index entry a stub standing in for a partitioned record? */ +export function isPartitionStub(entry: Record): boolean { + return String(entry["partition"] ?? "") === "high_risk"; +} diff --git a/js/packages/core/test/highRiskPartition.test.ts b/js/packages/core/test/highRiskPartition.test.ts new file mode 100644 index 00000000..bfdafe00 --- /dev/null +++ b/js/packages/core/test/highRiskPartition.test.ts @@ -0,0 +1,134 @@ +/** + * High-risk partition file format. + * + * The load-bearing tests are the interop ones: the fixtures were produced by + * Python, so "TS reads them" proves the format is genuinely shared rather + * than merely self-consistent. A partition only either implementation can + * open would be worse than none — the entries are SSH keys, PGP keys and + * seeds, and a user who cannot open them has lost them. + */ + +import { describe, expect, it } from "vitest"; +import { base64 } from "@scure/base"; +import { + decryptPartition, + encryptPartition, + buildPartitionEnvelope, + unwrapPartitionKey, + generatePartitionKey, + partitionKeyTag, + partitionStub, + isPartitionStub, + HighRiskError, +} from "@seedpass/core"; +import { highRiskFixture as fx } from "@seedpass/test-vectors"; + +describe("interop with Python", () => { + it("derives the same partition key tag", () => { + expect(partitionKeyTag(fx.partition_key)).toBe(fx.tag); + }); + + it("unwraps an envelope Python wrapped", async () => { + expect(await unwrapPartitionKey(fx.envelope, fx.factor)).toBe(fx.partition_key); + }); + + it("reads a partition file Python wrote", async () => { + const entries = await decryptPartition(base64.decode(fx.partition_file_b64), fx.tag); + expect(entries).toEqual(fx.entries); + }); + + it("writes a partition file that round-trips", async () => { + // Byte-identical output is impossible — the Fernet IV is random — so the + // check is that the payload survives, with the canonical JSON shape both + // implementations serialize. + const blob = await encryptPartition(fx.entries, fx.tag, { updatedAt: 1700000000 }); + expect(await decryptPartition(blob, fx.tag)).toEqual(fx.entries); + }); +}); + +describe("the factor is what protects the partition", () => { + it("refuses the wrong factor with Python's reason string", async () => { + await expect(unwrapPartitionKey(fx.envelope, "not-the-factor")).rejects.toMatchObject({ + reason: "high_risk_factor_invalid", + }); + }); + + it("refuses to decrypt a partition under the wrong tag", async () => { + const blob = base64.decode(fx.partition_file_b64); + const wrongTag = partitionKeyTag(generatePartitionKey()); + await expect(decryptPartition(blob, wrongTag)).rejects.toMatchObject({ + reason: "invalid_partition_key_tag", + }); + }); + + it("gives a different tag for every generated key", () => { + const tags = new Set(Array.from({ length: 16 }, () => partitionKeyTag(generatePartitionKey()))); + expect(tags.size).toBe(16); + }); + + it("rejects an envelope of an unknown version rather than guessing", async () => { + await expect( + unwrapPartitionKey({ ...fx.envelope, version: 99 }, fx.factor), + ).rejects.toMatchObject({ reason: "unsupported_partition_envelope_version" }); + }); + + it("rejects a truncated envelope", async () => { + for (const missing of ["salt_b64", "wrapped_partition_key"]) { + const envelope = { ...fx.envelope, [missing]: "" }; + await expect(unwrapPartitionKey(envelope, fx.factor)).rejects.toBeInstanceOf(HighRiskError); + } + }); + + it("round-trips a freshly built envelope", async () => { + const key = generatePartitionKey(); + // Low iteration count purely to keep the test quick; production uses the + // PARTITION_KDF_ITERATIONS default. + const envelope = await buildPartitionEnvelope(key, "correct horse", { iterations: 1000 }); + expect(await unwrapPartitionKey(envelope, "correct horse")).toBe(key); + await expect(unwrapPartitionKey(envelope, "wrong horse")).rejects.toBeInstanceOf( + HighRiskError, + ); + }); +}); + +describe("index stubs", () => { + const entry = { + kind: "ssh", + type: "ssh", + label: "prod-server", + index: 3, + notes: "deployment key", + archived: false, + modified_ts: 1700000000, + secret: "THE-ACTUAL-KEY-MATERIAL", + }; + + it("keeps nothing secret in the stub", () => { + const stub = partitionStub("3", entry, "ssh", 1700000000); + // A stub is what stays in the vault index, which is readable with the + // master password alone. It must disclose that a high-risk entry exists + // and nothing more — otherwise moving the entry bought nothing. + const serialized = JSON.stringify(stub); + expect(serialized).not.toContain("THE-ACTUAL-KEY-MATERIAL"); + expect(serialized).not.toContain("deployment key"); + expect(stub["label"]).toBe("prod-server"); + expect(stub["kind"]).toBe("ssh"); + expect(stub["partition"]).toBe("high_risk"); + expect(stub["partition_ref"]).toBe("3"); + }); + + it("is recognizable as a stub", () => { + expect(isPartitionStub(partitionStub("3", entry, "ssh", 1700000000))).toBe(true); + expect(isPartitionStub(entry)).toBe(false); + expect(isPartitionStub({})).toBe(false); + }); + + it("matches the shape Python leaves behind", () => { + // Python's migrate writes exactly these keys; a listing that reads one + // implementation's stub must not find the other's malformed. + const stub = partitionStub("3", entry, "ssh", 1700000000); + expect(Object.keys(stub).sort()).toEqual( + ["archived", "index", "kind", "label", "modified_ts", "partition", "partition_ref", "type"], + ); + }); +}); diff --git a/js/packages/test-vectors/fixtures/high_risk.json b/js/packages/test-vectors/fixtures/high_risk.json new file mode 100644 index 00000000..3eec16e4 --- /dev/null +++ b/js/packages/test-vectors/fixtures/high_risk.json @@ -0,0 +1,35 @@ +{ + "description": "high-risk partition envelope and file, for cross-impl checks", + "entries": { + "3": { + "archived": false, + "index": 3, + "kind": "ssh", + "label": "prod-server", + "modified_ts": 1700000000, + "notes": "deploy", + "type": "ssh" + }, + "5": { + "archived": false, + "index": 5, + "kind": "pgp", + "label": "signing", + "modified_ts": 1700000100, + "notes": "", + "type": "pgp" + } + }, + "envelope": { + "iterations": 200000, + "kdf": "pbkdf2-sha256", + "salt_b64": "AAECAwQFBgcICQoLDA0ODw==", + "version": 1, + "wrapped_partition_key": "gAAAAABqhbsiuZu-EfSBrvoDDpPRJXSPP30W1kGfWZz-WMbSHKjqJn_qHWYfiwi-4xhlczaRZKTUwANB-UIAo5XkAi7MbNwBlT_ElZaSQkskX85zbTuC8sbioHEUT1kASzV6rIbbta0e" + }, + "factor": "second-factor-passphrase", + "fixture_version": 1, + "partition_file_b64": "Z0FBQUFBQnFoYnNpU2FoTjlTZDF1MUNwV1JwLVRqNTRiWlplWXRRTXZhVzdqa2FoYUVMUl84NDVmdzdoNjJucFgzUzFWbFh5aGRuOW9UcWNfY3lLMTZpSTYySGo4LXVJM0tOYW0tVmc0bHhtOGVITkd4MWw4VEJOeGk3eF9NZWloQmoxWUJkaTg3RUlZdHZnWkVobE00cHQ0NzVucnRTNlJYR0RPMnozYV9pTVl0SVRqcjE3eUdRUkVwVzBuMHlCcGJxZ2VZbHRaallmclpIdlNkTnpwczRIdF9BeWVITHQtTER3S0tPMU9ZZU41VkkwWlRKdFg5eEw3SFIycm44MnNKTzNHVUFYVTNBWFRjbEx3Ri1vLVYxY2Y3cmQxUmstTHBSX0tvekZFZUxfdmZsV05aMGJ5WXgxMHFvbFlXZ1VYbTVyUWJ4ZG9BUXdFRklLVS1YZUxOTW1FekVVcGpXUGlicFktbm52NmR3Nlk0aW9RdUFGZmZwRlpnN091dDh5NUlqOXFwVEpGeGZSdEROVUtfTzhiUVdFamZqSHo5RGF1TmV0YWkzNkwyS3FnX0lJVTducEdzZ0xJbEVvRVFfX09Va1Byb0lWaTdMNlFnSDRmYUNpb2UxVGlsU19lQWMzRS1oRlZQSHpOM1ptRG05dU16QjdUQVZycGxWcUdlNWEtRjBoNkNncUU4SVo=", + "partition_key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "tag": "905f28def18eaac05ae6f12b2c3452744afaf626da1343d57b395b544e0519b6" +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index 2cdf6269..df00c35e 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -8,6 +8,7 @@ "entry_mods.json", "entry_secrets.json", "fingerprints.json", + "high_risk.json", "index_keys.json", "kdf_metadata.json", "legacy_payloads.json", @@ -30,6 +31,6 @@ ], "fixture_version": 1, "generator": "scripts/generate_ts_port_fixtures.py", - "python_commit": "51f1358da4d9a743ca5d079c78b827fa84599c89", + "python_commit": "885cbe5665498c442ad1d3bebabf5e010db77961", "redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile." } diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 35944bee..7507b811 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -10,6 +10,7 @@ import manifestJson from "../fixtures/manifest.json"; import qrJson from "../fixtures/qr.json"; import semanticJson from "../fixtures/semantic.json"; +import highRiskJson from "../fixtures/high_risk.json"; import bip39Json from "../fixtures/bip39_seeds.json"; import bip85Json from "../fixtures/bip85_entropy.json"; import passwordsV1Json from "../fixtures/passwords_v1.json"; @@ -333,3 +334,13 @@ export const semanticFixture = semanticJson as { >; kind_filtered: Array<{ entry_id: number }>; }; + +/** High-risk partition interop vectors, produced by the Python implementation. */ +export const highRiskFixture = highRiskJson as { + factor: string; + partition_key: string; + tag: string; + envelope: Record; + entries: Record>; + partition_file_b64: string; +}; diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index d6c00ef8..a53d6e9b 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -1356,6 +1356,69 @@ def gen_semantic() -> dict: } +def gen_high_risk() -> dict: + """Interop vectors for the high-risk partition. + + Both implementations read and write these two files, so the fixture is + what proves the format is genuinely shared rather than merely + self-consistent. A partition only one side can open would be worse than + none: the entries are SSH keys, PGP keys and seeds, and a user who cannot + open them has lost them. + + The salt and partition key are fixed so the envelope is reproducible; + production draws both at random. + """ + import tempfile + + from cryptography.fernet import Fernet + + from seedpass.core import agent_secret_isolation as iso + from seedpass.core import high_risk_partition_store as store + + factor = "second-factor-passphrase" + salt = bytes(range(16)) + partition_key = base64.urlsafe_b64encode(bytes(range(32))).decode("ascii") + + wrapping_key = iso._derive_wrapping_key(factor, salt, iso.PARTITION_KDF_ITERATIONS) + envelope = { + "version": iso.PARTITION_ENVELOPE_VERSION, + "kdf": "pbkdf2-sha256", + "iterations": iso.PARTITION_KDF_ITERATIONS, + "salt_b64": base64.b64encode(salt).decode("ascii"), + "wrapped_partition_key": Fernet(wrapping_key) + .encrypt(partition_key.encode("utf-8")) + .decode("utf-8"), + } + tag = iso._partition_key_tag(partition_key) + + entries = { + "3": { + "kind": "ssh", "type": "ssh", "label": "prod-server", "index": 3, + "notes": "deploy", "archived": False, "modified_ts": 1700000000, + }, + "5": { + "kind": "pgp", "type": "pgp", "label": "signing", "index": 5, + "notes": "", "archived": False, "modified_ts": 1700000100, + }, + } + + with tempfile.TemporaryDirectory() as tmpdir: + profile = Path(tmpdir) / "PROFILE" + profile.mkdir() + store.save_partition_entries(profile, tag, entries) + blob = (profile / store.PARTITION_FILENAME).read_bytes() + + return { + "description": "high-risk partition envelope and file, for cross-impl checks", + "factor": factor, + "partition_key": partition_key, + "tag": tag, + "envelope": envelope, + "entries": entries, + "partition_file_b64": base64.b64encode(blob).decode("ascii"), + } + + def main() -> None: FIXTURES_DIR.mkdir(parents=True, exist_ok=True) @@ -1381,6 +1444,7 @@ def main() -> None: "legacy_payloads.json": gen_legacy_payloads(), "qr.json": gen_qr(), "semantic.json": gen_semantic(), + "high_risk.json": gen_high_risk(), } entries_fixture, vault_fixture = gen_entries_and_vault() files["entries_index.json"] = entries_fixture