diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index f4cfce0..2d78e2b 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("2869fdd", "source"), + commit: defineString("9680ce9", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("66df1aadf54e", "source") + codeHash: defineString("57a2f6a3851c", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index fc53010..6687723 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("2869fdd", "source"), + commit: defineString("9680ce9", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("66df1aadf54e", "source") + codeHash: defineString("57a2f6a3851c", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; @@ -7124,6 +7124,15 @@ import { Database } from "bun:sqlite"; // src/backbone/store.ts var MAX_PENDING_PER_TARGET = 1000; +// src/backbone/token-hash.ts +import { createHash as createHash3 } from "crypto"; +function hashToken(raw) { + return createHash3("sha256").update(raw).digest("hex"); +} +function looksHashedToken(s) { + return /^[0-9a-f]{64}$/.test(s); +} + // src/backbone/store/sqlite-store.ts class SqliteStore { db; @@ -7191,6 +7200,12 @@ class SqliteStore { try { this.db.exec("ALTER TABLE rooms ADD COLUMN password_hash TEXT"); } catch {} + const legacyTokens = this.db.query("SELECT token, identity_id FROM auth_tokens").all(); + for (const r of legacyTokens) { + if (!looksHashedToken(r.token)) { + this.db.query("UPDATE auth_tokens SET token=? WHERE token=?").run(hashToken(r.token), r.token); + } + } } async upsertIdentity(id, displayName) { this.db.query("INSERT INTO identities(id, display_name) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET display_name=excluded.display_name").run(id, displayName); @@ -7284,16 +7299,19 @@ class SqliteStore { return rows.map((r) => JSON.parse(r.envelope)); } async issueToken(token, identityId) { - this.db.query("INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id").run(token, identityId); + this.db.query("INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id").run(hashToken(token), identityId); } async resolveToken(token) { - const row = this.db.query("SELECT identity_id FROM auth_tokens WHERE token=?").get(token); + const row = this.db.query("SELECT identity_id FROM auth_tokens WHERE token=?").get(hashToken(token)); return row ? row.identity_id : null; } async listTokens() { const rows = this.db.query("SELECT token, identity_id FROM auth_tokens").all(); return rows.map((r) => ({ token: r.token, identityId: r.identity_id })); } + async revokeTokens(identityId) { + return this.db.query("DELETE FROM auth_tokens WHERE identity_id=?").run(identityId).changes; + } async close() { if (this.closed) return; diff --git a/src/backbone/identity-service.ts b/src/backbone/identity-service.ts index f5d115f..33b9cd6 100644 --- a/src/backbone/identity-service.ts +++ b/src/backbone/identity-service.ts @@ -60,4 +60,14 @@ export class IdentityService { await this.store.issueToken(token, identityId); return token; } + + /** + * Revoke ALL PSK tokens bound to an identity (§11.3). Returns how many bindings were deleted. The + * identity must `abg auth login`/`issue` again to get a fresh token; old tokens are rejected at the + * broker's next authenticate. Does NOT close already-open connections (auth is checked at hello) — + * pair with `abg room remove` to evict a live session (membership is re-checked on delivery). + */ + async revokeTokens(identityId: string): Promise { + return this.store.revokeTokens(identityId); + } } diff --git a/src/backbone/identity/store-psk-identity-provider.ts b/src/backbone/identity/store-psk-identity-provider.ts index 3872063..5e44838 100644 --- a/src/backbone/identity/store-psk-identity-provider.ts +++ b/src/backbone/identity/store-psk-identity-provider.ts @@ -9,12 +9,12 @@ import type { Identity, IdentityProvider } from "../identity"; * (seeded at construction), this reads the live Store, so a freshly-issued token * authenticates without restarting the broker. * - * Note (security, MVP): tokens are stored raw in the local Store. The collab DB - * file itself is 0644 (bun:sqlite default), so its CONTAINING directory is locked - * to 0700 by the writer (`abg auth login`, src/cli/auth.ts) to block other local - * users — the durable equivalent of control-token.ts's 0600 file. The token is an - * unguessable randomUUID and the link is WireGuard-encrypted over Tailscale (§7). - * Hashing tokens at rest is a §11.3 hardening item. + * Note (security): tokens are stored HASHED at rest (SHA-256, §11.3 — see token-hash.ts); the raw + * token lives only in the edge's auth-token file (0600). The collab DB file itself is 0644 (bun:sqlite + * default), so its CONTAINING directory is still locked to 0700 by the writer (`abg auth login`, + * src/cli/auth.ts) — the identities table holds emails/PII even though the token rows are now digests — + * the durable equivalent of control-token.ts's 0600 file. The token is an unguessable randomUUID and + * the link is WireGuard-encrypted over Tailscale (§7). */ export class StorePskIdentityProvider implements IdentityProvider { constructor(private readonly store: Store) {} diff --git a/src/backbone/store.ts b/src/backbone/store.ts index ef7b4fd..8013331 100644 --- a/src/backbone/store.ts +++ b/src/backbone/store.ts @@ -108,8 +108,14 @@ export interface Store { issueToken(token: string, identityId: string): Promise; /** Resolve a presented token to its identity id, or null if unknown. */ resolveToken(token: string): Promise; - /** All issued (token, identityId) bindings — e.g. to seed an in-memory PSK provider. */ + /** + * All issued (token, identityId) bindings. NOTE: the token is the HASHED at-rest value (§11.3), NOT + * the raw token — so this is for inspection/migration, NOT for seeding a PskIdentityProvider (which + * compares raw tokens; seeding it from these hashes would fail every authentication). + */ listTokens(): Promise>; + /** Revoke ALL tokens bound to an identity (§11.3). Returns how many bindings were deleted. */ + revokeTokens(identityId: string): Promise; /** Release resources (close the DB handle). Idempotent. */ close(): Promise; diff --git a/src/backbone/store/memory-store.ts b/src/backbone/store/memory-store.ts index 0e4fa4a..a3d0d4d 100644 --- a/src/backbone/store/memory-store.ts +++ b/src/backbone/store/memory-store.ts @@ -1,5 +1,6 @@ import type { Envelope } from "../envelope"; import { MAX_PENDING_PER_TARGET } from "../store"; +import { hashToken } from "../token-hash"; import type { AgentRecord, IdentityRecord, @@ -166,17 +167,28 @@ export class InMemoryStore implements Store { } async issueToken(token: string, identityId: string): Promise { - this.tokens.set(token, identityId); // re-issue re-points + this.tokens.set(hashToken(token), identityId); // store the hash at rest (§11.3); re-issue re-points } async resolveToken(token: string): Promise { - return this.tokens.get(token) ?? null; + return this.tokens.get(hashToken(token)) ?? null; } async listTokens(): Promise> { return [...this.tokens.entries()].map(([token, identityId]) => ({ token, identityId })); } + async revokeTokens(identityId: string): Promise { + let n = 0; + for (const [t, id] of this.tokens) { + if (id === identityId) { + this.tokens.delete(t); + n++; + } + } + return n; + } + async close(): Promise { // No handle to release; idempotent no-op. } diff --git a/src/backbone/store/postgres-store.ts b/src/backbone/store/postgres-store.ts index f077077..d5e6135 100644 --- a/src/backbone/store/postgres-store.ts +++ b/src/backbone/store/postgres-store.ts @@ -128,6 +128,10 @@ export class PostgresStore implements Store { throw new Error(NOT_IMPLEMENTED); } + async revokeTokens(_identityId: string): Promise { + throw new Error(NOT_IMPLEMENTED); + } + async close(): Promise { throw new Error(NOT_IMPLEMENTED); } diff --git a/src/backbone/store/sqlite-store.ts b/src/backbone/store/sqlite-store.ts index b2b3b6f..a8c6308 100644 --- a/src/backbone/store/sqlite-store.ts +++ b/src/backbone/store/sqlite-store.ts @@ -1,6 +1,7 @@ import { Database } from "bun:sqlite"; import type { Envelope } from "../envelope"; import { MAX_PENDING_PER_TARGET } from "../store"; +import { hashToken, looksHashedToken } from "../token-hash"; import type { AgentRecord, IdentityRecord, @@ -84,6 +85,18 @@ export class SqliteStore implements Store { } catch { /* column already present (fresh DB or already-migrated) — no-op */ } + // Migration (§11.3): re-hash any pre-existing RAW auth tokens. issueToken now stores SHA-256(token); + // a row whose token isn't already a 64-hex digest is a legacy raw token → hash it in place. + // Idempotent — once every row is hashed this loop makes no changes. + const legacyTokens = this.db.query("SELECT token, identity_id FROM auth_tokens").all() as { + token: string; + identity_id: string; + }[]; + for (const r of legacyTokens) { + if (!looksHashedToken(r.token)) { + this.db.query("UPDATE auth_tokens SET token=? WHERE token=?").run(hashToken(r.token), r.token); + } + } } // --- identities --- @@ -283,19 +296,19 @@ export class SqliteStore implements Store { return rows.map((r) => JSON.parse(r.envelope) as Envelope); } - // --- auth tokens --- + // --- auth tokens (stored hashed at rest, §11.3: the raw token lives only in the edge's 0600 file) --- async issueToken(token: string, identityId: string): Promise { this.db .query( "INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?) ON CONFLICT(token) DO UPDATE SET identity_id=excluded.identity_id", ) - .run(token, identityId); + .run(hashToken(token), identityId); } async resolveToken(token: string): Promise { const row = this.db .query("SELECT identity_id FROM auth_tokens WHERE token=?") - .get(token) as { identity_id: string } | null; + .get(hashToken(token)) as { identity_id: string } | null; return row ? row.identity_id : null; } @@ -306,6 +319,10 @@ export class SqliteStore implements Store { return rows.map((r) => ({ token: r.token, identityId: r.identity_id })); } + async revokeTokens(identityId: string): Promise { + return this.db.query("DELETE FROM auth_tokens WHERE identity_id=?").run(identityId).changes; + } + async close(): Promise { if (this.closed) return; this.closed = true; diff --git a/src/backbone/token-hash.ts b/src/backbone/token-hash.ts new file mode 100644 index 0000000..fb08290 --- /dev/null +++ b/src/backbone/token-hash.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +/** + * Hash a PSK token for at-rest storage (§11.3). The token is a 128-bit random UUID (high entropy), + * so a fast cryptographic hash (SHA-256) is the right primitive: a DB leak then exposes only + * irreversible digests, while `resolveToken` stays a cheap hash+lookup on the broker's hot auth path. + * + * Unlike a low-entropy ROOM PASSWORD (which needs the memory-hard scrypt in password.ts to resist + * guessing), a token has no guessing surface — brute-forcing 128 bits of randomness is infeasible — so + * a single SHA-256 is sufficient and far cheaper. The raw token lives only in the edge's auth-token + * file (0600); the Store holds the hash. + */ +export function hashToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +/** True iff `s` already looks like a {@link hashToken} digest (64 lowercase hex) — drives the migration. */ +export function looksHashedToken(s: string): boolean { + return /^[0-9a-f]{64}$/.test(s); +} diff --git a/src/cli.ts b/src/cli.ts index 3a77031..f182159 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -191,6 +191,9 @@ Commands: On the edge: install a broker-issued token to /auth-token (0600) auth login --id --name Self-sign a token locally (single-machine case) and write it (0600) + auth revoke --id + On the broker: revoke all of an identity's tokens (old tokens can't reconnect). + Pair with "room remove" to evict a live session room create [--password | --password-stdin] | room list Create a collaboration room (id = slugified name) or list rooms. With a password, members can self-join via "abg join --password " diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 92fc057..6832c95 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -47,8 +47,8 @@ function resolveDbPath(dbPath?: string): string { } /** - * Lock the collab dir to 0700. The collab DB holds RAW PSK tokens (auth_tokens) + identity - * emails/PII (identities). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars are + * Lock the collab dir to 0700. The collab DB holds PSK tokens (auth_tokens — hashed at rest §11.3) + + * identity emails/PII (identities, still plaintext). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars are * recreated 0644 on every reopen, so file-level chmod is not durable — lock the CONTAINING * directory instead (matches codex-transport.ts), blocking any other local user from * traversing in to read the secrets (CWE-732). chmodSync covers a pre-existing looser dir. @@ -179,10 +179,42 @@ export async function runAuthIssueCli(argv: string[]): Promise { console.log(`已在本机(broker)store 为 ${result.identity.id}(${result.identity.displayName})签发令牌。`); console.log("把下面这行通过安全渠道带外发给对方,让它在自己机器上运行:"); console.log(` abg auth login --token ${result.token}`); - console.log("(注:对同一 --id 重复 issue 会另签新 token、旧 token 不会自动失效——令牌吊销 CLI 仍在 backlog。)"); + console.log("(注:对同一 --id 重复 issue 会另签新 token、旧 token 不会自动失效;要作废旧 token 用 abg auth revoke --id 。)"); } -/** Dispatch `abg auth `: `login` (install/self-sign) or `issue` (broker-side sign). */ +const REVOKE_USAGE = "用法:abg auth revoke --id (在 broker 机上吊销该身份的所有 token)"; + +/** + * Revoke ALL tokens bound to an identity (`abg auth revoke`, §11.3) — run on the broker machine where + * the collab DB lives. The identity must `auth login`/be re-invited to get a fresh token; old tokens + * are rejected at the broker's next authenticate. Directly unit-testable via an explicit `dbPath`. + */ +export async function authRevoke(opts: { id: string; dbPath?: string }): Promise<{ revoked: number }> { + const dbPath = resolveDbPath(opts.dbPath); + lockCollabDir(dbPath); + const store = new SqliteStore(dbPath); + try { + return { revoked: await new IdentityService(store).revokeTokens(opts.id) }; + } finally { + await store.close(); + } +} + +/** Run `abg auth revoke --id `: delete the identity's tokens so old ones can't reconnect. */ +export async function runAuthRevokeCli(argv: string[]): Promise { + const { id } = parseAuthArgs(argv); + if (!id) { + console.error("缺少必填参数 --id。"); + console.error(REVOKE_USAGE); + process.exit(1); + return; + } + const { revoked } = await authRevoke({ id }); + console.log(`已吊销 ${id} 的 ${revoked} 个令牌。该身份用旧 token 重连即被 broker 拒(4401)。`); + console.log(`注意:已在线的连接会保持到自然断开——要立刻踢出,请同时 abg room remove ${id}(投递时强制成员制)。`); +} + +/** Dispatch `abg auth `: `login` (install/self-sign) / `issue` (broker-side sign) / `revoke`. */ export async function runAuth(args: string[]): Promise { const sub = args[0]; switch (sub) { @@ -192,10 +224,14 @@ export async function runAuth(args: string[]): Promise { case "issue": await runAuthIssueCli(args.slice(1)); break; + case "revoke": + await runAuthRevokeCli(args.slice(1)); + break; default: console.error(`未知的 auth 子命令:${sub ?? "(空)"}`); console.error(LOGIN_USAGE); console.error(ISSUE_USAGE); + console.error(REVOKE_USAGE); process.exit(1); } } diff --git a/src/cli/broker.ts b/src/cli/broker.ts index 632b61d..6727e54 100644 --- a/src/cli/broker.ts +++ b/src/cli/broker.ts @@ -97,7 +97,7 @@ export async function runBrokerStart(argv: string[]): Promise { const dbPath = resolveDbPath(db); const dir = dirname(dbPath); - // Same 0700 lockdown as `abg auth login`: the collab DB holds raw PSK tokens + PII. + // Same 0700 lockdown as `abg auth login`: the collab DB holds PSK tokens (hashed at rest §11.3) + identity PII. mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); diff --git a/src/cli/room.ts b/src/cli/room.ts index bbb5cfa..25a9c83 100644 --- a/src/cli/room.ts +++ b/src/cli/room.ts @@ -61,8 +61,8 @@ export async function currentIdentityId(store: Store, dbPath: string): Promise { console.log("提示:AGENTBRIDGE_BROKER_URL 要在 daemon 启动那一刻就已设好、且持久——建议写进 ~/.zshrc / ~/.bashrc。"); console.log("daemon 在启动时读一次该变量;若 daemon 已在跑、或新开终端没设它,会回退本机 ws://127.0.0.1:4700/ws、"); console.log("静默收不到房间事件。设好变量后,先 agentbridge kill 再 agentbridge claude,让 daemon 带上这个地址。"); - console.log("(注:重复 invite 会另签一个新 token、旧 token 不会自动失效——令牌吊销 CLI 仍在 backlog。)"); + console.log("(注:重复 invite 会另签一个新 token、旧 token 不会自动失效;要作废旧 token 用 abg auth revoke --id 。)"); if (isLoopbackBrokerUrl(url)) { console.log(""); console.log(`⚠️ 上面的 broker 地址 ${url} 仅本机可达,对方跨机连不上。请改用 broker 机的可路由地址重发:`); diff --git a/src/collab-store.ts b/src/collab-store.ts index f0897f4..f20d81b 100644 --- a/src/collab-store.ts +++ b/src/collab-store.ts @@ -3,7 +3,7 @@ * lookups every collab entrypoint needs. Extracted so `abg publish`, the daemon * room bridge, and future consumers resolve the collab DB, auth token, and broker * URL identically (and lock the dir down identically), instead of each re-deriving - * them. The collab DB holds raw PSK tokens + PII, so its dir is forced to 0700. + * them. The collab DB holds PSK tokens (hashed at rest §11.3) + identity PII, so its dir is forced to 0700. */ import { chmodSync, mkdirSync, readFileSync } from "node:fs"; @@ -39,7 +39,7 @@ export function readAuthToken(dbPath: string): string | null { } } -/** Open the collab Store, locking the containing dir to 0700 (raw PSK tokens + PII live there). */ +/** Open the collab Store, locking the containing dir to 0700 (hashed PSK tokens §11.3 + identity PII live there). */ export function openStore(dbPath: string): SqliteStore { const dir = dirname(dbPath); mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/src/integration-test/broker-token-revoke.test.ts b/src/integration-test/broker-token-revoke.test.ts new file mode 100644 index 0000000..7511bf3 --- /dev/null +++ b/src/integration-test/broker-token-revoke.test.ts @@ -0,0 +1,51 @@ +/** + * Token revocation (§11.3): `abg auth revoke` deletes an identity's token bindings, so a presented + * (now-revoked) token no longer authenticates at the broker. Auth is checked at hello, so an + * already-open connection persists until it drops — pair revoke with `abg room remove` to evict a live + * session (membership is re-checked on delivery). This test pins both the rejection and the limitation. + */ + +import { describe, test, expect } from "bun:test"; +import { Broker } from "../broker"; +import { BrokerClient } from "../broker-client"; +import { InMemoryStore } from "../backbone/store/memory-store"; +import { IdentityService } from "../backbone/identity-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; + +describe("token revoke (§11.3) — broker rejects a revoked token on reconnect", () => { + test("valid before revoke; a NEW connection with the revoked token is rejected; the live one persists", async () => { + const store = new InMemoryStore(); + const svc = new IdentityService(store); + await svc.registerIdentity("bob@x.com", "Bob"); + const token = await svc.issueToken("bob@x.com"); + const broker = new Broker({ + store, + identityProvider: new StorePskIdentityProvider(store), + host: "127.0.0.1", + port: 0, + log: () => {}, + }); + const { port } = broker.start(); + const url = `ws://127.0.0.1:${port}/ws`; + const live = new BrokerClient({ url, token, log: () => {} }); + try { + expect(await live.connect()).toEqual({ id: "bob@x.com", displayName: "Bob" }); // valid before revoke + + expect(await svc.revokeTokens("bob@x.com")).toBe(1); // operator runs `abg auth revoke --id bob@x.com` + + // A fresh connection with the now-revoked token authenticates against the store → not found → reject. + const reconnect = new BrokerClient({ url, token, log: () => {} }); + try { + await expect(reconnect.connect()).rejects.toThrow(); + } finally { + reconnect.close(); + } + // The already-open connection stays authenticated (auth is hello-only) — the documented limitation. + expect(live.connected).toBe(true); + } finally { + live.close(); + broker.stop(); + await store.close(); + } + }); +}); diff --git a/src/unit-test/cli-auth.test.ts b/src/unit-test/cli-auth.test.ts index da4b7c7..68d9aa8 100644 --- a/src/unit-test/cli-auth.test.ts +++ b/src/unit-test/cli-auth.test.ts @@ -2,9 +2,10 @@ import { afterEach, describe, expect, it } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { authIssue, authLogin, installToken } from "../cli/auth"; +import { authIssue, authLogin, authRevoke, installToken } from "../cli/auth"; import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; import { SqliteStore } from "../backbone/store/sqlite-store"; +import { IdentityService } from "../backbone/identity-service"; describe("authLogin", () => { let dir: string | undefined; @@ -119,3 +120,33 @@ describe("installToken (edge: abg auth login --token)", () => { expect(existsSync(join(dir, "auth-token"))).toBe(false); }); }); + +describe("authRevoke", () => { + let dir: string | undefined; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("revokes ALL of an identity's tokens so they no longer resolve, reports the count, is idempotent", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-revoke-")); + const dbPath = join(dir, "collab.db"); + const store = new SqliteStore(dbPath); + const svc = new IdentityService(store); + await svc.registerIdentity("alice@x.com", "Alice"); + const t1 = await svc.issueToken("alice@x.com"); + const t2 = await svc.issueToken("alice@x.com"); + await store.close(); + + expect((await authRevoke({ id: "alice@x.com", dbPath })).revoked).toBe(2); + + const check = new SqliteStore(dbPath); + try { + expect(await check.resolveToken(t1)).toBeNull(); // revoked → no longer authenticates + expect(await check.resolveToken(t2)).toBeNull(); + } finally { + await check.close(); + } + expect((await authRevoke({ id: "alice@x.com", dbPath })).revoked).toBe(0); // nothing left → idempotent + }); +}); diff --git a/src/unit-test/store-contract.ts b/src/unit-test/store-contract.ts index d13ed34..c572949 100644 --- a/src/unit-test/store-contract.ts +++ b/src/unit-test/store-contract.ts @@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { MAX_PENDING_PER_TARGET } from "../backbone/store"; import type { Store } from "../backbone/store"; import { makeEnvelope } from "./backbone-fixtures"; +import { hashToken } from "../backbone/token-hash"; /** * Shared Store contract (NOT a *.test.ts — imported by per-impl driver tests). @@ -139,7 +140,7 @@ export function runStoreContract(label: string, makeStore: () => Store) { expect(keys.has(`k${total - 1}`)).toBe(true); }); - test("auth tokens issue / resolve / list, re-issue re-points", async () => { + test("auth tokens issue / resolve / list (hashed at rest §11.3), re-issue re-points, revoke deletes", async () => { expect(await store.resolveToken("tok-1")).toBeNull(); await store.issueToken("tok-1", "alice@x.com"); await store.issueToken("tok-2", "bob@x.com"); @@ -148,8 +149,16 @@ export function runStoreContract(label: string, makeStore: () => Store) { // re-issuing the same token re-points it to a new identity await store.issueToken("tok-1", "carol@x.com"); expect(await store.resolveToken("tok-1")).toBe("carol@x.com"); + // §11.3: tokens are stored HASHED at rest — listTokens exposes digests, never the raw tokens const all = (await store.listTokens()).map((t) => `${t.token}:${t.identityId}`).sort(); - expect(all).toEqual(["tok-1:carol@x.com", "tok-2:bob@x.com"]); + expect(all).toEqual([`${hashToken("tok-1")}:carol@x.com`, `${hashToken("tok-2")}:bob@x.com`].sort()); + // revokeTokens removes ALL of an identity's tokens, returns the count, and is idempotent + await store.issueToken("tok-3", "carol@x.com"); + expect(await store.revokeTokens("carol@x.com")).toBe(2); // tok-1 + tok-3 + expect(await store.resolveToken("tok-1")).toBeNull(); + expect(await store.resolveToken("tok-3")).toBeNull(); + expect(await store.resolveToken("tok-2")).toBe("bob@x.com"); // bob untouched + expect(await store.revokeTokens("carol@x.com")).toBe(0); }); }); } diff --git a/src/unit-test/token-hash.test.ts b/src/unit-test/token-hash.test.ts new file mode 100644 index 0000000..d1d598d --- /dev/null +++ b/src/unit-test/token-hash.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hashToken, looksHashedToken } from "../backbone/token-hash"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import { InMemoryStore } from "../backbone/store/memory-store"; + +describe("backbone/token-hash (§11.3 at-rest token hashing)", () => { + test("hashToken: deterministic 64-hex SHA-256, never the raw token", () => { + const raw = "11111111-2222-3333-4444-555555555555"; + const h = hashToken(raw); + expect(h).toMatch(/^[0-9a-f]{64}$/); + expect(h).toBe(hashToken(raw)); // deterministic + expect(h).not.toContain(raw); // irreversible digest, not the plaintext + expect(hashToken("other")).not.toBe(h); + }); + + test("looksHashedToken: a digest yes, a raw UUID / empty no", () => { + expect(looksHashedToken(hashToken("x"))).toBe(true); + expect(looksHashedToken("11111111-2222-3333-4444-555555555555")).toBe(false); + expect(looksHashedToken("")).toBe(false); + }); + + for (const impl of [ + { name: "SqliteStore", mk: (dir: string) => new SqliteStore(join(dir, "collab.db")) }, + { name: "InMemoryStore", mk: (_dir: string) => new InMemoryStore() }, + ]) { + test(`${impl.name}: stores the token HASHED, resolves by raw, revoke deletes by identity`, async () => { + const dir = mkdtempSync(join(tmpdir(), "abg-tokh-")); + const store = impl.mk(dir); + try { + await store.issueToken("raw-token-abc", "alice@x.com"); + // at-rest: the persisted value is the hash, never the raw token + expect(await store.listTokens()).toEqual([{ token: hashToken("raw-token-abc"), identityId: "alice@x.com" }]); + // resolve still works when the RAW token is presented (the edge holds the raw token) + expect(await store.resolveToken("raw-token-abc")).toBe("alice@x.com"); + expect(await store.resolveToken("wrong")).toBeNull(); + // revoke removes ALL of the identity's tokens and reports the count + await store.issueToken("raw-token-2", "alice@x.com"); + expect(await store.revokeTokens("alice@x.com")).toBe(2); + expect(await store.resolveToken("raw-token-abc")).toBeNull(); + expect(await store.resolveToken("raw-token-2")).toBeNull(); + expect(await store.revokeTokens("alice@x.com")).toBe(0); // idempotent — nothing left + } finally { + await store.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + } + + test("SqliteStore: migrates a legacy RAW token row to a hash on reopen, still resolves by raw", async () => { + const dir = mkdtempSync(join(tmpdir(), "abg-tokmig-")); + const dbPath = join(dir, "collab.db"); + try { + new SqliteStore(dbPath).close(); // create the schema, then close + // simulate a pre-§11.3 DB: a RAW token written directly (the old issueToken stored plaintext) + const raw = new Database(dbPath); + raw.query("INSERT INTO auth_tokens(token, identity_id) VALUES(?, ?)").run("legacy-raw-uuid", "bob@x.com"); + raw.close(); + + // reopen → the constructor migration re-hashes the legacy row in place + const store = new SqliteStore(dbPath); + try { + expect(await store.listTokens()).toEqual([{ token: hashToken("legacy-raw-uuid"), identityId: "bob@x.com" }]); + expect(await store.resolveToken("legacy-raw-uuid")).toBe("bob@x.com"); // still authenticates by raw + } finally { + await store.close(); + } + // reopen a SECOND time: the already-hashed row must be left untouched (migration is idempotent) + const again = new SqliteStore(dbPath); + try { + expect(await again.listTokens()).toEqual([{ token: hashToken("legacy-raw-uuid"), identityId: "bob@x.com" }]); + expect(await again.resolveToken("legacy-raw-uuid")).toBe("bob@x.com"); + } finally { + await again.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});