mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 02:42:04 +00:00
TS port slice 10: scoped tokens, agent-side enforcement, audit chain
The section-9.3 permission model is now working code, enforced where the keys live: - Token model (aligned with the Python agent surface): bearer tokens with read/use/reveal scopes, entry-kind and label-regex constraints, TTL and use counts. Issued and held by the session agent — hash-stored, plaintext shown exactly once at issuance; list/revoke commands. - Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the decrypted index and materializes secrets AGENT-SIDE. The constrained process never receives the mnemonic, and resolveMnemonic refuses owner fallback in token mode — a token can never silently escalate (the test suite caught exactly this hole and it is now closed). - Denials are precise and audited: wrong scope, wrong kind, unmatched label, expired, exhausted, revoked. Reads don't consume uses; secret deliveries do. - Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index, prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log; agent records unlocks, token lifecycle, index reads, deliveries and denials (never secret values). audit-verify/audit-tail commands; a forged record breaks the chain in tests. - 151 core + 41 CLI tests green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
e84a7e1c0e
commit
274bc0e1b8
7 files changed
+710
-28
No files matched your search
@@ -43,13 +43,14 @@ until a later milestone.
|
|||||||
| Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. |
|
| Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. |
|
||||||
| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/<id>`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password, nostr get-pubkey/list-relays/add-relay/remove-relay/sync/restore. Milestone 5 command surface complete except ssh/pgp adds and document import/export; leases/tokens are Milestone 8. |
|
| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/<id>`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password, nostr get-pubkey/list-relays/add-relay/remove-relay/sync/restore. Milestone 5 command surface complete except ssh/pgp adds and document import/export; leases/tokens are Milestone 8. |
|
||||||
| Profiles + config (Python `~/.seedpass` layout) | P1 | green* | — | `fingerprint list/add/switch/remove`, `config get/set` over the Python directory layout (fingerprints.json, parent_seed.enc kdf/ct wrapper, index-key-encrypted config with ConfigManager defaults). *Layout-compatible and tested end-to-end in TS; opening a real Python-created profile is verified indirectly via the parent-seed wrapper fixture — direct cross-open test TODO. |
|
| Profiles + config (Python `~/.seedpass` layout) | P1 | green* | — | `fingerprint list/add/switch/remove`, `config get/set` over the Python directory layout (fingerprints.json, parent_seed.enc kdf/ct wrapper, index-key-encrypted config with ConfigManager defaults). *Layout-compatible and tested end-to-end in TS; opening a real Python-created profile is verified indirectly via the parent-seed wrapper fixture — direct cross-open test TODO. |
|
||||||
|
| Scoped tokens + audit chain (plan §9.3 / M8 core) | P1 | green | — (live agent tests) | Bearer tokens issued/held by the agent (hash-stored, shown once) with read/use/reveal scopes, kind + label-regex constraints, TTL and use counts; token-mode CLI reads the index and materializes secrets agent-side, never sees the mnemonic, and can never escalate to owner ops. Audit log uses Python AuditLogger's chain scheme (HMAC(prev_sig+canonical payload), KEY_INDEX key); verify/tail commands; tamper detection tested. Not yet: approval gates, high-risk partitions, persistent token store. |
|
||||||
| Session agent (vault unlock/lock) | new (TS-only) | green | — | ssh-agent-style unix-socket daemon holding seeds with TTL (0600 socket, in-memory only); seed resolution env -> agent; full lifecycle tested with no mnemonic in the environment. Designed enforcement point for the section-9.3 lease/token layer. Python has no equivalent (its lock is in-process TUI state). |
|
| Session agent (vault unlock/lock) | new (TS-only) | green | — | ssh-agent-style unix-socket daemon holding seeds with TTL (0600 socket, in-memory only); seed resolution env -> agent; full lifecycle tested with no mnemonic in the environment. Designed enforcement point for the section-9.3 lease/token layer. Python has no equivalent (its lock is in-process TUI state). |
|
||||||
|
|
||||||
## Environment coverage
|
## Environment coverage
|
||||||
|
|
||||||
| Environment | Status |
|
| Environment | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Node 22 (vitest) | green — 151/151 core + 32 CLI |
|
| Node 22 (vitest) | green — 151/151 core + 41 CLI |
|
||||||
| jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) |
|
| jsdom browser-like env | green — 148/148 core (3 transport tests Node-only) |
|
||||||
| Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI |
|
| Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI |
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,34 @@
|
|||||||
import { createServer, createConnection, type Socket } from "node:net";
|
import { createServer, createConnection, type Socket } from "node:net";
|
||||||
import { chmod, rm } from "node:fs/promises";
|
import { chmod, rm } from "node:fs/promises";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { deriveKeyIndex, sha256Hex, utf8, type Entry } from "@seedpass/core";
|
||||||
|
import { openVault } from "./vaultFile.js";
|
||||||
|
import { materializeSecret } from "./secrets.js";
|
||||||
|
import { AuditLog } from "./audit.js";
|
||||||
|
import { INDEX_FILENAME } from "./appDir.js";
|
||||||
|
|
||||||
export const DEFAULT_TTL_SECONDS = 900;
|
export const DEFAULT_TTL_SECONDS = 900;
|
||||||
|
|
||||||
|
export type TokenScope = "read" | "use" | "reveal";
|
||||||
|
|
||||||
|
export interface TokenRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fingerprint: string;
|
||||||
|
secret_hash: string;
|
||||||
|
scopes: TokenScope[];
|
||||||
|
kinds: string[] | null;
|
||||||
|
label_regex: string;
|
||||||
|
expires_at: number;
|
||||||
|
uses_remaining: number;
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenInfo = Omit<TokenRecord, "secret_hash">;
|
||||||
|
|
||||||
export function agentSocketPath(appDir: string): string {
|
export function agentSocketPath(appDir: string): string {
|
||||||
return process.env["SEEDPASS_AGENT_SOCK"] ?? join(appDir, "agent.sock");
|
return process.env["SEEDPASS_AGENT_SOCK"] ?? join(appDir, "agent.sock");
|
||||||
}
|
}
|
||||||
@@ -39,13 +62,64 @@ export interface AgentStatusProfile {
|
|||||||
|
|
||||||
export class AgentDaemon {
|
export class AgentDaemon {
|
||||||
private held = new Map<string, Held>();
|
private held = new Map<string, Held>();
|
||||||
|
private tokens = new Map<string, TokenRecord>();
|
||||||
|
private audits = new Map<string, AuditLog>();
|
||||||
private server = createServer((socket) => this.serve(socket));
|
private server = createServer((socket) => this.serve(socket));
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly socketPath: string,
|
private readonly socketPath: string,
|
||||||
private readonly defaultTtl = DEFAULT_TTL_SECONDS,
|
private readonly defaultTtl = DEFAULT_TTL_SECONDS,
|
||||||
|
/** Profile root; required for token-mode vault/secret serving. */
|
||||||
|
private readonly appDir?: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private audit(fingerprint: string): AuditLog | null {
|
||||||
|
if (!this.appDir) return null;
|
||||||
|
const held = this.held.get(fingerprint);
|
||||||
|
if (!held) return null;
|
||||||
|
let log = this.audits.get(fingerprint);
|
||||||
|
if (!log) {
|
||||||
|
log = new AuditLog(
|
||||||
|
join(this.appDir, fingerprint, "audit.log"),
|
||||||
|
deriveKeyIndex(held.mnemonic),
|
||||||
|
);
|
||||||
|
this.audits.set(fingerprint, log);
|
||||||
|
}
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a bearer token for an action against an entry, consuming one
|
||||||
|
* use on success. Returns the token record or a denial reason.
|
||||||
|
*/
|
||||||
|
private authorize(
|
||||||
|
tokenSecret: string,
|
||||||
|
fingerprint: string,
|
||||||
|
action: TokenScope,
|
||||||
|
entry?: { kind: string; label: string },
|
||||||
|
): { token?: TokenRecord; deny?: string } {
|
||||||
|
const hash = sha256Hex(utf8(tokenSecret));
|
||||||
|
const token = [...this.tokens.values()].find(
|
||||||
|
(t) => t.secret_hash === hash && t.fingerprint === fingerprint,
|
||||||
|
);
|
||||||
|
if (!token) return { deny: "unknown token" };
|
||||||
|
if (token.revoked) return { deny: "token revoked" };
|
||||||
|
if (token.expires_at <= Date.now() / 1000) return { deny: "token expired" };
|
||||||
|
if (token.uses_remaining <= 0) return { deny: "token exhausted" };
|
||||||
|
if (!token.scopes.includes(action)) return { deny: `scope '${action}' not granted` };
|
||||||
|
if (entry) {
|
||||||
|
if (token.kinds && !token.kinds.includes(entry.kind)) {
|
||||||
|
return { deny: `kind '${entry.kind}' not granted` };
|
||||||
|
}
|
||||||
|
if (!new RegExp(token.label_regex).test(entry.label)) {
|
||||||
|
return { deny: "label not matched by token constraint" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "read" is not consumption; secret-bearing actions decrement uses
|
||||||
|
if (action !== "read") token.uses_remaining -= 1;
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
if (existsSync(this.socketPath)) {
|
if (existsSync(this.socketPath)) {
|
||||||
// A live agent refuses to be replaced; a stale socket file is cleaned.
|
// A live agent refuses to be replaced; a stale socket file is cleaned.
|
||||||
@@ -73,9 +147,112 @@ export class AgentDaemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handle(msg: Record<string, unknown>): Record<string, unknown> {
|
private async handle(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
this.expire();
|
this.expire();
|
||||||
switch (msg["op"]) {
|
switch (msg["op"]) {
|
||||||
|
case "token-issue": {
|
||||||
|
// Owner-only op: local socket (0600) is the trust boundary, and the
|
||||||
|
// profile must currently be unlocked in this agent.
|
||||||
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||||
|
if (!this.held.has(fingerprint)) return { ok: false, error: "profile not unlocked" };
|
||||||
|
const scopes = (msg["scopes"] as TokenScope[] | undefined) ?? ["read"];
|
||||||
|
const bad = scopes.filter((s) => !["read", "use", "reveal"].includes(s));
|
||||||
|
if (bad.length) return { ok: false, error: `unknown scopes: ${bad.join(",")}` };
|
||||||
|
const secret = randomBytes(24).toString("base64url");
|
||||||
|
const record: TokenRecord = {
|
||||||
|
id: `tok-${randomBytes(6).toString("hex")}`,
|
||||||
|
name: String(msg["name"] ?? "agent"),
|
||||||
|
fingerprint,
|
||||||
|
secret_hash: sha256Hex(utf8(secret)),
|
||||||
|
scopes,
|
||||||
|
kinds: (msg["kinds"] as string[] | undefined) ?? null,
|
||||||
|
label_regex: String(msg["label_regex"] ?? ".*"),
|
||||||
|
expires_at: Math.floor(Date.now() / 1000 + Number(msg["ttl"] ?? 300)),
|
||||||
|
uses_remaining: Number(msg["uses"] ?? 1),
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
this.tokens.set(record.id, record);
|
||||||
|
await this.audit(fingerprint)?.log("token_issued", {
|
||||||
|
token_id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
scopes: record.scopes,
|
||||||
|
kinds: record.kinds,
|
||||||
|
label_regex: record.label_regex,
|
||||||
|
uses: record.uses_remaining,
|
||||||
|
});
|
||||||
|
// The plaintext token is returned exactly once, at issuance.
|
||||||
|
return { ok: true, token: secret, record: { ...record, secret_hash: undefined } };
|
||||||
|
}
|
||||||
|
case "token-list": {
|
||||||
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||||
|
const list = [...this.tokens.values()]
|
||||||
|
.filter((t) => t.fingerprint === fingerprint)
|
||||||
|
.map(({ secret_hash: _hash, ...info }) => info);
|
||||||
|
return { ok: true, tokens: list };
|
||||||
|
}
|
||||||
|
case "token-revoke": {
|
||||||
|
const token = this.tokens.get(String(msg["token_id"] ?? ""));
|
||||||
|
if (!token) return { ok: false, error: "unknown token id" };
|
||||||
|
token.revoked = true;
|
||||||
|
await this.audit(token.fingerprint)?.log("token_revoked", { token_id: token.id });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
case "vault-index": {
|
||||||
|
// Token-mode read of the decrypted index (metadata access).
|
||||||
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||||
|
const held = this.held.get(fingerprint);
|
||||||
|
if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" };
|
||||||
|
const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, "read");
|
||||||
|
if (!auth.token) {
|
||||||
|
await this.audit(fingerprint)?.log("access_denied", {
|
||||||
|
action: "read", reason: auth.deny,
|
||||||
|
});
|
||||||
|
return { ok: false, error: `denied: ${auth.deny}` };
|
||||||
|
}
|
||||||
|
const vault = await openVault(
|
||||||
|
join(this.appDir, fingerprint, INDEX_FILENAME),
|
||||||
|
held.mnemonic,
|
||||||
|
);
|
||||||
|
await this.audit(fingerprint)?.log("index_read", { token_id: auth.token.id });
|
||||||
|
return { ok: true, index: vault.index };
|
||||||
|
}
|
||||||
|
case "secret": {
|
||||||
|
// Token-mode secret materialization for a single entry.
|
||||||
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||||
|
const held = this.held.get(fingerprint);
|
||||||
|
if (!held || !this.appDir) return { ok: false, error: "profile not unlocked" };
|
||||||
|
const action = msg["action"] === "reveal" ? "reveal" : "use";
|
||||||
|
const id = String(msg["id"] ?? "");
|
||||||
|
const vault = await openVault(
|
||||||
|
join(this.appDir, fingerprint, INDEX_FILENAME),
|
||||||
|
held.mnemonic,
|
||||||
|
);
|
||||||
|
const entry = vault.index.entries[id] as Entry | undefined;
|
||||||
|
if (!entry) return { ok: false, error: `no entry ${id}` };
|
||||||
|
const auth = this.authorize(String(msg["token"] ?? ""), fingerprint, action, {
|
||||||
|
kind: entry.kind,
|
||||||
|
label: entry.label,
|
||||||
|
});
|
||||||
|
if (!auth.token) {
|
||||||
|
await this.audit(fingerprint)?.log("access_denied", {
|
||||||
|
action, entry_id: id, kind: entry.kind, reason: auth.deny,
|
||||||
|
});
|
||||||
|
return { ok: false, error: `denied: ${auth.deny}` };
|
||||||
|
}
|
||||||
|
const ts = msg["timestamp"] !== undefined ? Number(msg["timestamp"]) : undefined;
|
||||||
|
const secret = materializeSecret(vault.index, id, entry, held.mnemonic, {
|
||||||
|
...(ts !== undefined && { timestamp: ts }),
|
||||||
|
});
|
||||||
|
await this.audit(fingerprint)?.log("secret_delivered", {
|
||||||
|
action,
|
||||||
|
entry_id: id,
|
||||||
|
kind: entry.kind,
|
||||||
|
label: entry.label,
|
||||||
|
token_id: auth.token.id,
|
||||||
|
uses_remaining: auth.token.uses_remaining,
|
||||||
|
});
|
||||||
|
return { ok: true, value: secret.value, descriptor: secret.descriptor };
|
||||||
|
}
|
||||||
case "put": {
|
case "put": {
|
||||||
const fingerprint = String(msg["fingerprint"] ?? "");
|
const fingerprint = String(msg["fingerprint"] ?? "");
|
||||||
const mnemonic = String(msg["mnemonic"] ?? "");
|
const mnemonic = String(msg["mnemonic"] ?? "");
|
||||||
@@ -83,6 +260,7 @@ export class AgentDaemon {
|
|||||||
const ttl = Number(msg["ttl"] ?? this.defaultTtl);
|
const ttl = Number(msg["ttl"] ?? this.defaultTtl);
|
||||||
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
|
const expiresAt = Math.floor(Date.now() / 1000 + ttl);
|
||||||
this.held.set(fingerprint, { mnemonic, expiresAt });
|
this.held.set(fingerprint, { mnemonic, expiresAt });
|
||||||
|
await this.audit(fingerprint)?.log("vault_unlocked", { ttl });
|
||||||
return { ok: true, expires_at: expiresAt };
|
return { ok: true, expires_at: expiresAt };
|
||||||
}
|
}
|
||||||
case "get": {
|
case "get": {
|
||||||
@@ -123,13 +301,15 @@ export class AgentDaemon {
|
|||||||
while ((nl = buffer.indexOf("\n")) >= 0) {
|
while ((nl = buffer.indexOf("\n")) >= 0) {
|
||||||
const line = buffer.slice(0, nl);
|
const line = buffer.slice(0, nl);
|
||||||
buffer = buffer.slice(nl + 1);
|
buffer = buffer.slice(nl + 1);
|
||||||
let reply: Record<string, unknown>;
|
void (async () => {
|
||||||
try {
|
let reply: Record<string, unknown>;
|
||||||
reply = this.handle(JSON.parse(line) as Record<string, unknown>);
|
try {
|
||||||
} catch (e) {
|
reply = await this.handle(JSON.parse(line) as Record<string, unknown>);
|
||||||
reply = { ok: false, error: String(e) };
|
} catch (e) {
|
||||||
}
|
reply = { ok: false, error: String(e) };
|
||||||
socket.write(JSON.stringify(reply) + "\n");
|
}
|
||||||
|
socket.write(JSON.stringify(reply) + "\n");
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on("error", () => socket.destroy());
|
socket.on("error", () => socket.destroy());
|
||||||
@@ -201,4 +381,63 @@ export class AgentClient {
|
|||||||
const r = await this.request({ op: "status" });
|
const r = await this.request({ op: "status" });
|
||||||
return (r["profiles"] ?? []) as AgentStatusProfile[];
|
return (r["profiles"] ?? []) as AgentStatusProfile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async tokenIssue(options: {
|
||||||
|
fingerprint: string;
|
||||||
|
name?: string;
|
||||||
|
scopes?: TokenScope[];
|
||||||
|
kinds?: string[];
|
||||||
|
labelRegex?: string;
|
||||||
|
ttl?: number;
|
||||||
|
uses?: number;
|
||||||
|
}): Promise<{ token: string; record: TokenInfo }> {
|
||||||
|
const r = await this.request({
|
||||||
|
op: "token-issue",
|
||||||
|
fingerprint: options.fingerprint,
|
||||||
|
name: options.name,
|
||||||
|
scopes: options.scopes,
|
||||||
|
kinds: options.kinds,
|
||||||
|
label_regex: options.labelRegex,
|
||||||
|
ttl: options.ttl,
|
||||||
|
uses: options.uses,
|
||||||
|
});
|
||||||
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||||
|
return { token: String(r["token"]), record: r["record"] as TokenInfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokenList(fingerprint: string): Promise<TokenInfo[]> {
|
||||||
|
const r = await this.request({ op: "token-list", fingerprint });
|
||||||
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||||
|
return r["tokens"] as TokenInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokenRevoke(tokenId: string): Promise<void> {
|
||||||
|
const r = await this.request({ op: "token-revoke", token_id: tokenId });
|
||||||
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async vaultIndex(fingerprint: string, token: string): Promise<unknown> {
|
||||||
|
const r = await this.request({ op: "vault-index", fingerprint, token });
|
||||||
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||||
|
return r["index"];
|
||||||
|
}
|
||||||
|
|
||||||
|
async secret(options: {
|
||||||
|
fingerprint: string;
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
action: "use" | "reveal";
|
||||||
|
timestamp?: number;
|
||||||
|
}): Promise<{ value: string; descriptor: string }> {
|
||||||
|
const r = await this.request({
|
||||||
|
op: "secret",
|
||||||
|
fingerprint: options.fingerprint,
|
||||||
|
id: options.id,
|
||||||
|
token: options.token,
|
||||||
|
action: options.action,
|
||||||
|
...(options.timestamp !== undefined && { timestamp: options.timestamp }),
|
||||||
|
});
|
||||||
|
if (!r["ok"]) throw new Error(String(r["error"]));
|
||||||
|
return { value: String(r["value"]), descriptor: String(r["descriptor"]) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Tamper-evident audit log, chain-compatible with Python's AuditLogger:
|
||||||
|
* each record's sig = HMAC-SHA256(key, prev_sig + canonical_payload) where
|
||||||
|
* canonical_payload is the compact sort_keys JSON of {timestamp, event,
|
||||||
|
* details} and the genesis prev_sig is 64 zeros. The signing key is the
|
||||||
|
* profile's index-derivation key (KEY_INDEX), so only the seed holder can
|
||||||
|
* extend or verify the chain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { appendFile, readFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { canonicalJson, hmacSha256Hex, utf8 } from "@seedpass/core";
|
||||||
|
|
||||||
|
const GENESIS = "0".repeat(64);
|
||||||
|
|
||||||
|
export interface AuditRecord {
|
||||||
|
timestamp: string;
|
||||||
|
event: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoNow(): string {
|
||||||
|
return new Date().toISOString().replace("Z", "+00:00");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(key: Uint8Array, prevSig: string, payload: string): string {
|
||||||
|
return hmacSha256Hex(key, utf8(prevSig + payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditLog {
|
||||||
|
private lastSig = GENESIS;
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly path: string,
|
||||||
|
private readonly key: Uint8Array,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async loadTail(): Promise<void> {
|
||||||
|
if (this.loaded) return;
|
||||||
|
this.loaded = true;
|
||||||
|
if (!existsSync(this.path)) return;
|
||||||
|
const lines = (await readFile(this.path, "utf8")).split("\n").filter((l) => l.trim());
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
if (last) {
|
||||||
|
try {
|
||||||
|
this.lastSig = (JSON.parse(last) as AuditRecord).sig ?? GENESIS;
|
||||||
|
} catch {
|
||||||
|
// keep genesis; verify() will surface corruption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async log(event: string, details: Record<string, unknown> = {}): Promise<void> {
|
||||||
|
await this.loadTail();
|
||||||
|
const entry = { timestamp: isoNow(), event, details };
|
||||||
|
const payload = canonicalJson(entry);
|
||||||
|
const sig = signPayload(this.key, this.lastSig, payload);
|
||||||
|
await appendFile(this.path, canonicalJson({ ...entry, sig }) + "\n", { mode: 0o600 });
|
||||||
|
this.lastSig = sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify the whole chain; returns records or throws at the first break. */
|
||||||
|
static async verify(path: string, key: Uint8Array): Promise<AuditRecord[]> {
|
||||||
|
if (!existsSync(path)) return [];
|
||||||
|
const lines = (await readFile(path, "utf8")).split("\n").filter((l) => l.trim());
|
||||||
|
const records: AuditRecord[] = [];
|
||||||
|
let prevSig = GENESIS;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let record: AuditRecord;
|
||||||
|
try {
|
||||||
|
record = JSON.parse(lines[i]!) as AuditRecord;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`audit chain broken at line ${i + 1}: unparseable record`);
|
||||||
|
}
|
||||||
|
const payload = canonicalJson({
|
||||||
|
timestamp: record.timestamp,
|
||||||
|
event: record.event,
|
||||||
|
details: record.details,
|
||||||
|
});
|
||||||
|
const expected = signPayload(key, prevSig, payload);
|
||||||
|
if (expected !== record.sig) {
|
||||||
|
throw new Error(`audit chain broken at line ${i + 1}: signature mismatch`);
|
||||||
|
}
|
||||||
|
prevSig = record.sig;
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,11 +35,22 @@ export function capabilities(): Record<string, unknown> {
|
|||||||
layout: "python-compatible ~/.seedpass",
|
layout: "python-compatible ~/.seedpass",
|
||||||
commands: ["fingerprint list/add/switch/remove", "config get/set"],
|
commands: ["fingerprint list/add/switch/remove", "config get/set"],
|
||||||
},
|
},
|
||||||
|
tokens: {
|
||||||
|
commands: ["agent token-issue/token-list/token-revoke"],
|
||||||
|
scopes: ["read", "use", "reveal"],
|
||||||
|
constraints: ["kinds", "label_regex", "ttl", "uses"],
|
||||||
|
token_env_var: "SEEDPASS_TOKEN",
|
||||||
|
enforcement: "session agent (secrets materialized agent-side in token mode)",
|
||||||
|
escalation: "token mode can never fall back to owner access",
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
commands: ["agent audit-verify", "agent audit-tail"],
|
||||||
|
chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX",
|
||||||
|
},
|
||||||
not_yet_ported: [
|
not_yet_ported: [
|
||||||
"ssh/pgp key material",
|
"ssh/pgp key material",
|
||||||
"relay sync commands",
|
|
||||||
"index0/atlas",
|
"index0/atlas",
|
||||||
"leases and scoped tokens (design: plan section 9.3; enforcement point: session agent)",
|
"approval gates and high-risk partitions",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+172
-17
@@ -55,6 +55,7 @@ import { join } from "node:path";
|
|||||||
import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js";
|
import { AppDir, resolveAppDir, INDEX_FILENAME } from "./appDir.js";
|
||||||
import { loadConfig, saveConfig } from "./configFile.js";
|
import { loadConfig, saveConfig } from "./configFile.js";
|
||||||
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
|
import { AgentClient, AgentDaemon, agentSocketPath, DEFAULT_TTL_SECONDS } from "./agent.js";
|
||||||
|
import { AuditLog } from "./audit.js";
|
||||||
|
|
||||||
export interface ProgramIo {
|
export interface ProgramIo {
|
||||||
out(line: string): void;
|
out(line: string): void;
|
||||||
@@ -86,6 +87,15 @@ async function currentFingerprint(app: AppDir, opts: GlobalOpts): Promise<string
|
|||||||
async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise<string> {
|
async function resolveMnemonic(app: AppDir, opts: GlobalOpts): Promise<string> {
|
||||||
const env = process.env["SEEDPASS_MNEMONIC"];
|
const env = process.env["SEEDPASS_MNEMONIC"];
|
||||||
if (env) return env;
|
if (env) return env;
|
||||||
|
// A process that authenticates with a scoped token has declared itself a
|
||||||
|
// constrained principal: it must never escalate to owner access by
|
||||||
|
// pulling the mnemonic from the unlocked agent.
|
||||||
|
if (process.env["SEEDPASS_TOKEN"]) {
|
||||||
|
throw new Error(
|
||||||
|
"token mode: this operation requires owner access (unset SEEDPASS_TOKEN " +
|
||||||
|
"and unlock as the owner)",
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fp = await currentFingerprint(app, opts);
|
const fp = await currentFingerprint(app, opts);
|
||||||
const client = new AgentClient(agentSocketPath(app.root));
|
const client = new AgentClient(agentSocketPath(app.root));
|
||||||
@@ -121,6 +131,60 @@ async function openFromOptions(opts: GlobalOpts): Promise<OpenedVault> {
|
|||||||
return openVault(path, await resolveMnemonic(app, opts));
|
return openVault(path, await resolveMnemonic(app, opts));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read/secret access that works in two modes:
|
||||||
|
* - owner mode (mnemonic via env or agent): full local access
|
||||||
|
* - token mode (SEEDPASS_TOKEN set, no mnemonic): every read and secret
|
||||||
|
* goes through the agent, which enforces the token's scopes/kinds/label
|
||||||
|
* constraints and writes the audit trail. The mnemonic never reaches
|
||||||
|
* this process.
|
||||||
|
*/
|
||||||
|
interface ReadAccess {
|
||||||
|
index: import("@seedpass/core").VaultIndex;
|
||||||
|
mode: "owner" | "token";
|
||||||
|
secretFor(
|
||||||
|
id: string,
|
||||||
|
action: "use" | "reveal",
|
||||||
|
timestamp?: number,
|
||||||
|
): Promise<{ value: string; descriptor: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReadAccess(opts: GlobalOpts): Promise<ReadAccess> {
|
||||||
|
const token = process.env["SEEDPASS_TOKEN"];
|
||||||
|
const app = new AppDir(resolveAppDir(opts.appDir));
|
||||||
|
if (token && !process.env["SEEDPASS_MNEMONIC"]) {
|
||||||
|
const fp = await currentFingerprint(app, opts);
|
||||||
|
const client = new AgentClient(agentSocketPath(app.root));
|
||||||
|
const index = parseVaultIndex(await client.vaultIndex(fp, token));
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
mode: "token",
|
||||||
|
secretFor: async (id, action, timestamp) => {
|
||||||
|
return client.secret({
|
||||||
|
fingerprint: fp,
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
action,
|
||||||
|
...(timestamp !== undefined && { timestamp }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const vault = await openFromOptions(opts);
|
||||||
|
return {
|
||||||
|
index: vault.index,
|
||||||
|
mode: "owner",
|
||||||
|
secretFor: async (id, action, timestamp) => {
|
||||||
|
void action;
|
||||||
|
const entry = vault.index.entries[id];
|
||||||
|
if (!entry) throw new Error(`no entry ${id}`);
|
||||||
|
return materializeSecret(vault.index, id, entry, vault.mnemonic, {
|
||||||
|
...(timestamp !== undefined && { timestamp }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProgram(io: ProgramIo = defaultIo): Command {
|
export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program
|
program
|
||||||
@@ -148,8 +212,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
.command("list")
|
.command("list")
|
||||||
.description("list entries as references + metadata (no secrets)")
|
.description("list entries as references + metadata (no secrets)")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const vault = await openFromOptions(program.opts());
|
const access = await openReadAccess(program.opts());
|
||||||
const rows = Object.entries(vault.index.entries).map(([id, e]) =>
|
const rows = Object.entries(access.index.entries).map(([id, e]) =>
|
||||||
entryMetadata(id, e),
|
entryMetadata(id, e),
|
||||||
);
|
);
|
||||||
io.out(JSON.stringify(rows, null, 2));
|
io.out(JSON.stringify(rows, null, 2));
|
||||||
@@ -159,8 +223,8 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
.command("get <refOrQuery>")
|
.command("get <refOrQuery>")
|
||||||
.description("show one entry's reference + metadata (no secrets)")
|
.description("show one entry's reference + metadata (no secrets)")
|
||||||
.action(async (refOrQuery: string) => {
|
.action(async (refOrQuery: string) => {
|
||||||
const vault = await openFromOptions(program.opts());
|
const access = await openReadAccess(program.opts());
|
||||||
const hit = resolveEntry(vault.index, refOrQuery);
|
const hit = resolveEntry(access.index, refOrQuery);
|
||||||
io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2));
|
io.out(JSON.stringify(entryMetadata(hit.id, hit.entry), null, 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,9 +232,9 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
.command("search <text>")
|
.command("search <text>")
|
||||||
.description("substring search over labels/tags/notes (no secrets)")
|
.description("substring search over labels/tags/notes (no secrets)")
|
||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const vault = await openFromOptions(program.opts());
|
const access = await openReadAccess(program.opts());
|
||||||
const needle = text.toLowerCase();
|
const needle = text.toLowerCase();
|
||||||
const rows = Object.entries(vault.index.entries)
|
const rows = Object.entries(access.index.entries)
|
||||||
.filter(([, e]) => {
|
.filter(([, e]) => {
|
||||||
const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase();
|
const hay = [e.label, e.notes ?? "", ...(e.tags ?? [])].join("\n").toLowerCase();
|
||||||
return hay.includes(needle);
|
return hay.includes(needle);
|
||||||
@@ -460,11 +524,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
.description("PLAINTEXT EGRESS: print the secret to stdout")
|
.description("PLAINTEXT EGRESS: print the secret to stdout")
|
||||||
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
|
.option("--at <timestamp>", "TOTP: unix time to compute the code at")
|
||||||
.action(async (refOrQuery: string, cmdOpts: { at?: string }) => {
|
.action(async (refOrQuery: string, cmdOpts: { at?: string }) => {
|
||||||
const vault = await openFromOptions(program.opts());
|
const access = await openReadAccess(program.opts());
|
||||||
const hit = resolveEntry(vault.index, refOrQuery);
|
const hit = resolveEntry(access.index, refOrQuery);
|
||||||
const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, {
|
const secret = await access.secretFor(
|
||||||
...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }),
|
hit.id,
|
||||||
});
|
"reveal",
|
||||||
|
cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined,
|
||||||
|
);
|
||||||
io.out(secret.value);
|
io.out(secret.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -486,11 +552,13 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
if (chosen !== 1) {
|
if (chosen !== 1) {
|
||||||
throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to");
|
throw new Error("choose exactly one sink: --clipboard, --exec, or --stdin-to");
|
||||||
}
|
}
|
||||||
const vault = await openFromOptions(program.opts());
|
const access = await openReadAccess(program.opts());
|
||||||
const hit = resolveEntry(vault.index, refOrQuery);
|
const hit = resolveEntry(access.index, refOrQuery);
|
||||||
const secret = materializeSecret(vault.index, hit.id, hit.entry, vault.mnemonic, {
|
const secret = await access.secretFor(
|
||||||
...(cmdOpts.at !== undefined && { timestamp: Number(cmdOpts.at) }),
|
hit.id,
|
||||||
});
|
"use",
|
||||||
|
cmdOpts.at !== undefined ? Number(cmdOpts.at) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (cmdOpts.clipboard) {
|
if (cmdOpts.clipboard) {
|
||||||
@@ -747,12 +815,99 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
|||||||
.option("--ttl <seconds>", "default unlock TTL", String(DEFAULT_TTL_SECONDS))
|
.option("--ttl <seconds>", "default unlock TTL", String(DEFAULT_TTL_SECONDS))
|
||||||
.action(async (o: { ttl: string }) => {
|
.action(async (o: { ttl: string }) => {
|
||||||
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
|
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
|
||||||
const daemon = new AgentDaemon(agentSocketPath(app.root), Number(o.ttl));
|
const daemon = new AgentDaemon(agentSocketPath(app.root), Number(o.ttl), app.root);
|
||||||
await daemon.start();
|
await daemon.start();
|
||||||
io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) }));
|
io.out(JSON.stringify({ agent: "running", socket: agentSocketPath(app.root) }));
|
||||||
await new Promise(() => {}); // run until killed
|
await new Promise(() => {}); // run until killed
|
||||||
});
|
});
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command("token-issue")
|
||||||
|
.description("issue a scoped bearer token (printed exactly once)")
|
||||||
|
.option("--name <name>", "token name", "agent")
|
||||||
|
.option("--scope <scope...>", "read/use/reveal (repeatable)", ["read"])
|
||||||
|
.option("--kind <kind...>", "restrict to entry kinds (repeatable)")
|
||||||
|
.option("--label-regex <re>", "restrict to matching labels", ".*")
|
||||||
|
.option("--ttl <seconds>", "token lifetime", "300")
|
||||||
|
.option("--uses <n>", "max secret deliveries", "1")
|
||||||
|
.action(
|
||||||
|
async (o: {
|
||||||
|
name: string; scope: string[]; kind?: string[]; labelRegex: string;
|
||||||
|
ttl: string; uses: string;
|
||||||
|
}) => {
|
||||||
|
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));
|
||||||
|
const issued = await client.tokenIssue({
|
||||||
|
fingerprint: fp,
|
||||||
|
name: o.name,
|
||||||
|
scopes: o.scope as ("read" | "use" | "reveal")[],
|
||||||
|
...(o.kind !== undefined && { kinds: o.kind }),
|
||||||
|
labelRegex: o.labelRegex,
|
||||||
|
ttl: Number(o.ttl),
|
||||||
|
uses: Number(o.uses),
|
||||||
|
});
|
||||||
|
io.out(
|
||||||
|
JSON.stringify(
|
||||||
|
{ token: issued.token, note: "shown once — store it now", record: issued.record },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command("token-list")
|
||||||
|
.description("list issued tokens (no secrets)")
|
||||||
|
.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(await client.tokenList(fp), null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command("token-revoke <tokenId>")
|
||||||
|
.description("revoke a token immediately")
|
||||||
|
.action(async (tokenId: string) => {
|
||||||
|
const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir));
|
||||||
|
const client = new AgentClient(agentSocketPath(app.root));
|
||||||
|
await client.tokenRevoke(tokenId);
|
||||||
|
io.out(JSON.stringify({ revoked: tokenId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command("audit-verify")
|
||||||
|
.description("verify the profile's HMAC-chained audit log (owner only)")
|
||||||
|
.action(async () => {
|
||||||
|
const opts = program.opts() as GlobalOpts;
|
||||||
|
const app = new AppDir(resolveAppDir(opts.appDir));
|
||||||
|
const fp = await currentFingerprint(app, opts);
|
||||||
|
const mnemonic = await resolveMnemonic(app, opts);
|
||||||
|
const path = join(app.profileDir(fp), "audit.log");
|
||||||
|
const records = await AuditLog.verify(path, deriveKeyIndex(mnemonic));
|
||||||
|
io.out(JSON.stringify({ verified: true, records: records.length, path }));
|
||||||
|
});
|
||||||
|
|
||||||
|
agent
|
||||||
|
.command("audit-tail")
|
||||||
|
.description("show the last audit records after verifying the chain (owner only)")
|
||||||
|
.option("-n <count>", "records to show", "10")
|
||||||
|
.action(async (o: { n: string }) => {
|
||||||
|
const opts = program.opts() as GlobalOpts;
|
||||||
|
const app = new AppDir(resolveAppDir(opts.appDir));
|
||||||
|
const fp = await currentFingerprint(app, opts);
|
||||||
|
const mnemonic = await resolveMnemonic(app, opts);
|
||||||
|
const records = await AuditLog.verify(
|
||||||
|
join(app.profileDir(fp), "audit.log"),
|
||||||
|
deriveKeyIndex(mnemonic),
|
||||||
|
);
|
||||||
|
io.out(JSON.stringify(records.slice(-Number(o.n)), null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("show unlocked profiles and expiry times")
|
.description("show unlocked profiles and expiry times")
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Scoped-token enforcement tests: a token-holding process with NO mnemonic
|
||||||
|
* anywhere in its environment gets exactly what its scopes grant — reads,
|
||||||
|
* sink deliveries for permitted kinds/labels, nothing else — with every
|
||||||
|
* decision landing in the tamper-evident audit log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, readFile, writeFile, appendFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { mnemonics } from "@seedpass/test-vectors";
|
||||||
|
import { generateFingerprint } from "@seedpass/core";
|
||||||
|
import { buildProgram, AgentDaemon, agentSocketPath, type ProgramIo } from "../src/index.js";
|
||||||
|
|
||||||
|
const MNEMONIC = mnemonics["abandon12"]!;
|
||||||
|
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
||||||
|
const PASSWORD = "token-test-pw";
|
||||||
|
|
||||||
|
let appDir: string;
|
||||||
|
let daemon: AgentDaemon;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
...argv: string[]
|
||||||
|
): Promise<{ stdout: string; error?: unknown }> {
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
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 io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
|
||||||
|
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"), error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const asOwner = { SEEDPASS_MNEMONIC: MNEMONIC };
|
||||||
|
const asTokenHolder = (t: string) => ({
|
||||||
|
SEEDPASS_MNEMONIC: undefined,
|
||||||
|
SEEDPASS_TOKEN: t,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
appDir = await mkdtemp(join(tmpdir(), "seedpass-tokens-"));
|
||||||
|
process.env["SEEDPASS_AGENT_SOCK"] = join(appDir, "agent.sock");
|
||||||
|
daemon = new AgentDaemon(agentSocketPath(appDir), 900, appDir);
|
||||||
|
await daemon.start();
|
||||||
|
|
||||||
|
await run({ ...asOwner, SEEDPASS_PASSWORD: PASSWORD }, "fingerprint", "add");
|
||||||
|
await run(asOwner, "entry", "add", "key-value", "api-token", "k", "sinkable-secret");
|
||||||
|
await run(asOwner, "entry", "add", "key-value", "internal-db", "k", "off-limits");
|
||||||
|
await run(asOwner, "entry", "add", "password", "site-login", "--length", "16");
|
||||||
|
await run({ SEEDPASS_MNEMONIC: undefined, SEEDPASS_PASSWORD: PASSWORD }, "vault", "unlock");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await daemon.stop();
|
||||||
|
delete process.env["SEEDPASS_AGENT_SOCK"];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token issuance", () => {
|
||||||
|
it("issues a scoped token, printed exactly once", async () => {
|
||||||
|
const r = await run(
|
||||||
|
{},
|
||||||
|
"agent", "token-issue",
|
||||||
|
"--name", "deploy-bot",
|
||||||
|
"--scope", "read", "use",
|
||||||
|
"--kind", "key_value",
|
||||||
|
"--label-regex", "^api-",
|
||||||
|
"--ttl", "600",
|
||||||
|
"--uses", "2",
|
||||||
|
);
|
||||||
|
const issued = JSON.parse(r.stdout);
|
||||||
|
token = issued.token;
|
||||||
|
expect(token.length).toBeGreaterThan(20);
|
||||||
|
expect(issued.record.scopes).toEqual(["read", "use"]);
|
||||||
|
expect(issued.record).not.toHaveProperty("secret_hash");
|
||||||
|
|
||||||
|
const list = JSON.parse((await run({}, "agent", "token-list")).stdout);
|
||||||
|
expect(list[0].name).toBe("deploy-bot");
|
||||||
|
expect(JSON.stringify(list)).not.toContain(token);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token-mode access (no mnemonic anywhere)", () => {
|
||||||
|
it("reads metadata but the index never exposes the mnemonic", async () => {
|
||||||
|
const r = await run(asTokenHolder(token), "entry", "list");
|
||||||
|
const rows = JSON.parse(r.stdout) as { label: string }[];
|
||||||
|
expect(rows.map((x) => x.label).sort()).toEqual(["api-token", "internal-db", "site-login"]);
|
||||||
|
expect(r.stdout).not.toContain("sinkable-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a permitted secret to a sink", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-tok-sink-"));
|
||||||
|
const capture = join(dir, "captured.txt");
|
||||||
|
const script = join(dir, "cap.sh");
|
||||||
|
await writeFile(script, `#!/bin/sh\nprintf '%s' "$SEEDPASS_SECRET" > "${capture}"\n`, {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
const r = await run(asTokenHolder(token), "use", "api-token", "--exec", script);
|
||||||
|
expect(JSON.parse(r.stdout).exitCode).toBe(0);
|
||||||
|
expect(await readFile(capture, "utf8")).toBe("sinkable-secret");
|
||||||
|
expect(r.stdout).not.toContain("sinkable-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies reveal (scope), wrong kind, and non-matching label", async () => {
|
||||||
|
const reveal = await run(asTokenHolder(token), "entry", "reveal", "api-token");
|
||||||
|
expect(String((reveal.error as Error).message)).toContain("scope 'reveal' not granted");
|
||||||
|
|
||||||
|
const wrongKind = await run(asTokenHolder(token), "use", "site-login", "--stdin-to", "cat");
|
||||||
|
expect(String((wrongKind.error as Error).message)).toContain("kind 'password' not granted");
|
||||||
|
|
||||||
|
const wrongLabel = await run(asTokenHolder(token), "use", "internal-db", "--stdin-to", "cat");
|
||||||
|
expect(String((wrongLabel.error as Error).message)).toContain("label not matched");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exhausts after the allotted uses", async () => {
|
||||||
|
// Use #2 of 2 (use #1 was the sink delivery above; denials don't consume)
|
||||||
|
const second = await run(asTokenHolder(token), "use", "api-token", "--stdin-to", "cat");
|
||||||
|
expect(second.error).toBeUndefined();
|
||||||
|
const third = await run(asTokenHolder(token), "use", "api-token", "--stdin-to", "cat");
|
||||||
|
expect(String((third.error as Error).message)).toContain("token exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revocation cuts off reads too", async () => {
|
||||||
|
const list = JSON.parse((await run({}, "agent", "token-list")).stdout);
|
||||||
|
await run({}, "agent", "token-revoke", list[0].id);
|
||||||
|
const denied = await run(asTokenHolder(token), "entry", "list");
|
||||||
|
expect(String((denied.error as Error).message)).toContain("token revoked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("token mode cannot provision or modify", async () => {
|
||||||
|
const r = await run(asTokenHolder(token), "entry", "add", "key-value", "x", "k", "v");
|
||||||
|
expect(r.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audit chain", () => {
|
||||||
|
it("recorded the whole story and verifies", async () => {
|
||||||
|
const r = await run(asOwner, "agent", "audit-verify");
|
||||||
|
const summary = JSON.parse(r.stdout);
|
||||||
|
expect(summary.verified).toBe(true);
|
||||||
|
expect(summary.records).toBeGreaterThanOrEqual(6);
|
||||||
|
|
||||||
|
const tail = JSON.parse((await run(asOwner, "agent", "audit-tail", "-n", "50")).stdout) as {
|
||||||
|
event: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}[];
|
||||||
|
const events = tail.map((r) => r.event);
|
||||||
|
expect(events).toContain("token_issued");
|
||||||
|
expect(events).toContain("secret_delivered");
|
||||||
|
expect(events).toContain("access_denied");
|
||||||
|
expect(events).toContain("token_revoked");
|
||||||
|
const denial = tail.find((r) => r.event === "access_denied")!;
|
||||||
|
expect(String(denial.details["reason"])).toBeTruthy();
|
||||||
|
// Secrets never appear in the audit log
|
||||||
|
expect(JSON.stringify(tail)).not.toContain("sinkable-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects tampering", async () => {
|
||||||
|
const path = join(appDir, FINGERPRINT, "audit.log");
|
||||||
|
await appendFile(path, JSON.stringify({ timestamp: "x", event: "forged", details: {}, sig: "00" }) + "\n");
|
||||||
|
const r = await run(asOwner, "agent", "audit-verify");
|
||||||
|
expect(String((r.error as Error).message)).toContain("audit chain broken");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
/** Small byte/string helpers shared across the core. No Node or DOM APIs. */
|
/** Small byte/string helpers shared across the core. No Node or DOM APIs. */
|
||||||
|
|
||||||
import { sha256 } from "@noble/hashes/sha2.js";
|
import { sha256 } from "@noble/hashes/sha2.js";
|
||||||
|
import { hmac } from "@noble/hashes/hmac.js";
|
||||||
|
|
||||||
export function sha256Hex(data: Uint8Array): string {
|
export function sha256Hex(data: Uint8Array): string {
|
||||||
return bytesToHex(sha256(data));
|
return bytesToHex(sha256(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hmacSha256Hex(key: Uint8Array, data: Uint8Array): string {
|
||||||
|
return bytesToHex(hmac(sha256, key, data));
|
||||||
|
}
|
||||||
|
|
||||||
export function utf8(s: string): Uint8Array {
|
export function utf8(s: string): Uint8Array {
|
||||||
return new TextEncoder().encode(s);
|
return new TextEncoder().encode(s);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user