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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/agentbridge/server/bridge-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 22 additions & 4 deletions plugins/agentbridge/server/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions src/backbone/identity-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
return this.store.revokeTokens(identityId);
}
}
12 changes: 6 additions & 6 deletions src/backbone/identity/store-psk-identity-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down
8 changes: 7 additions & 1 deletion src/backbone/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,14 @@ export interface Store {
issueToken(token: string, identityId: string): Promise<void>;
/** Resolve a presented token to its identity id, or null if unknown. */
resolveToken(token: string): Promise<string | null>;
/** 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<Array<{ token: string; identityId: string }>>;
/** Revoke ALL tokens bound to an identity (§11.3). Returns how many bindings were deleted. */
revokeTokens(identityId: string): Promise<number>;

/** Release resources (close the DB handle). Idempotent. */
close(): Promise<void>;
Expand Down
16 changes: 14 additions & 2 deletions src/backbone/store/memory-store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -166,17 +167,28 @@ export class InMemoryStore implements Store {
}

async issueToken(token: string, identityId: string): Promise<void> {
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<string | null> {
return this.tokens.get(token) ?? null;
return this.tokens.get(hashToken(token)) ?? null;
}

async listTokens(): Promise<Array<{ token: string; identityId: string }>> {
return [...this.tokens.entries()].map(([token, identityId]) => ({ token, identityId }));
}

async revokeTokens(identityId: string): Promise<number> {
let n = 0;
for (const [t, id] of this.tokens) {
if (id === identityId) {
this.tokens.delete(t);
n++;
}
}
return n;
}

async close(): Promise<void> {
// No handle to release; idempotent no-op.
}
Expand Down
4 changes: 4 additions & 0 deletions src/backbone/store/postgres-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export class PostgresStore implements Store {
throw new Error(NOT_IMPLEMENTED);
}

async revokeTokens(_identityId: string): Promise<number> {
throw new Error(NOT_IMPLEMENTED);
}

async close(): Promise<void> {
throw new Error(NOT_IMPLEMENTED);
}
Expand Down
23 changes: 20 additions & 3 deletions src/backbone/store/sqlite-store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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<void> {
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<string | null> {
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;
}

Expand All @@ -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<number> {
return this.db.query("DELETE FROM auth_tokens WHERE identity_id=?").run(identityId).changes;
}

async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
Expand Down
20 changes: 20 additions & 0 deletions src/backbone/token-hash.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ Commands:
On the edge: install a broker-issued token to <state>/auth-token (0600)
auth login --id <email|github> --name <displayName>
Self-sign a token locally (single-machine case) and write it (0600)
auth revoke --id <email|github>
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 <name> [--password <pw> | --password-stdin] | room list
Create a collaboration room (id = slugified name) or list rooms. With a
password, members can self-join via "abg join <id> --password <pw>"
Expand Down
44 changes: 40 additions & 4 deletions src/cli/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -179,10 +179,42 @@ export async function runAuthIssueCli(argv: string[]): Promise<void> {
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 <id>。)");
}

/** Dispatch `abg auth <subcommand>`: `login` (install/self-sign) or `issue` (broker-side sign). */
const REVOKE_USAGE = "用法:abg auth revoke --id <email|github>(在 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 <id>`: delete the identity's tokens so old ones can't reconnect. */
export async function runAuthRevokeCli(argv: string[]): Promise<void> {
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 <roomId> ${id}(投递时强制成员制)。`);
}

/** Dispatch `abg auth <subcommand>`: `login` (install/self-sign) / `issue` (broker-side sign) / `revoke`. */
export async function runAuth(args: string[]): Promise<void> {
const sub = args[0];
switch (sub) {
Expand All @@ -192,10 +224,14 @@ export async function runAuth(args: string[]): Promise<void> {
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);
}
}
2 changes: 1 addition & 1 deletion src/cli/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export async function runBrokerStart(argv: string[]): Promise<void> {

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);

Expand Down
6 changes: 3 additions & 3 deletions src/cli/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export async function currentIdentityId(store: Store, dbPath: string): Promise<s
/** Open the collab Store with the same 0700 lockdown as `abg auth login`. */
function openStore(dbPath: string): SqliteStore {
const dir = dirname(dbPath);
// The collab DB holds raw PSK tokens + PII; lock the containing dir to 0700
// (matches auth.ts/broker.ts — bun:sqlite files are 0644 so dir is the gate).
// The collab DB holds PSK tokens (hashed at rest §11.3) + identity PII; lock the containing dir to
// 0700 (matches auth.ts/broker.ts — bun:sqlite files are 0644 so dir is the gate).
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
return new SqliteStore(dbPath);
Expand Down Expand Up @@ -424,7 +424,7 @@ export async function runRoom(args: string[]): Promise<void> {
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 <id>。)");
if (isLoopbackBrokerUrl(url)) {
console.log("");
console.log(`⚠️ 上面的 broker 地址 ${url} 仅本机可达,对方跨机连不上。请改用 broker 机的可路由地址重发:`);
Expand Down
4 changes: 2 additions & 2 deletions src/collab-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
Loading