From f31aff1bc6f9991bb9eae8ade58b1785dc8e131d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 12:35:47 -0700 Subject: [PATCH] fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loopback sessions list echoed live tokens — a harvest-and-replay primitive for anything on the machine (same class as the /health token leak fixed in v1.63). The list now returns a device-salted 16-hex token_id plus metadata; the salt is shared with the attempts log so identifiers correlate. /auth/revoke keeps the list→revoke workflow alive by accepting token_id alongside the caller's own raw token and identity. saltedHash() is exported from audit.ts and writeAttempt now reuses it (was inlined). Integration tests pin raw-token absence, the id shape/metadata, and the token_id revoke round-trip (verified RED against the leaking handler). List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours. Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 --- ios-qa/daemon/src/audit.ts | 10 +++- ios-qa/daemon/src/index.ts | 28 ++++++++-- ios-qa/daemon/test/daemon-integration.test.ts | 51 +++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/ios-qa/daemon/src/audit.ts b/ios-qa/daemon/src/audit.ts index 9ee4cf739..7b328ca39 100644 --- a/ios-qa/daemon/src/audit.ts +++ b/ios-qa/daemon/src/audit.ts @@ -60,14 +60,20 @@ export async function writeAudit(row: AuditRow, path: string = defaultAuditPath( await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 }); } +// Non-reversible identifier for tokens/identities in logs and API responses. +// Same device salt as the attempts log, so ids correlate across both. +export async function saltedHash(raw: string): Promise { + const salt = await loadDeviceSalt(); + return createHash('sha256').update(salt + ':' + raw).digest('hex').slice(0, 16); +} + export async function writeAttempt(opts: { rawIdentity: string; endpoint: string; reason: AttemptRow['reason']; path?: string; }): Promise { - const salt = await loadDeviceSalt(); - const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16); + const hash = await saltedHash(opts.rawIdentity); const row: AttemptRow = { ts: new Date().toISOString(), identity_canon: hash, diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index f3a70c89f..9ae177ca7 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -19,7 +19,7 @@ import { probeTailscale, whoIs } from './tailscale-localapi'; import { SessionTokenStore } from './session-tokens'; import { mintForCaller } from './auth-mint'; import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; -import { writeAudit, writeAttempt, sanitizeReplacer } from './audit'; +import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit'; import { bootstrapTunnel } from './tunnel-bootstrap'; import { startTunnelKeepalive } from './devicectl'; import type { Capability } from './types'; @@ -362,20 +362,38 @@ async function handleLoopback(ctx: HandlerCtx): Promise { return; } - // /auth/sessions — list active sessions (owner only). + // /auth/sessions — list active sessions (owner only). Raw token values + // never leave the store: any local process can hit this listener, so a + // list that echoed live bearer tokens was a harvest-and-replay primitive. + // Callers get a salted-hash id plus metadata; revoke by identity, by the + // token they already hold from mint, or by token_id from this list. if (method === 'GET' && path === '/auth/sessions') { - sendJson(res, 200, { sessions: tokenStore.list() }); + const sessions = await Promise.all(tokenStore.list().map(async ({ token, ...meta }) => ({ + token_id: await saltedHash(token), + ...meta, + }))); + sendJson(res, 200, { sessions }); return; } - // /auth/revoke — revoke a token. + // /auth/revoke — revoke by raw token (the caller's own, from mint), by + // token_id (from /auth/sessions — keeps the list→revoke workflow alive + // now that the list is hash-only), or by identity. if (method === 'POST' && path === '/auth/revoke') { const body = await readBody(req); if ('error' in body) { sendJson(res, 413, body); return; } - const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string }; + const parsed = JSON.parse(body.toString('utf-8') || '{}') as { + token?: string; token_id?: string; identity?: string; + }; let count = 0; if (parsed.token) { count = tokenStore.revoke(parsed.token) ? 1 : 0; + } else if (parsed.token_id) { + for (const s of tokenStore.list()) { + if ((await saltedHash(s.token)) === parsed.token_id) { + count += tokenStore.revoke(s.token) ? 1 : 0; + } + } } else if (parsed.identity) { count = tokenStore.revokeByIdentity(parsed.identity); } diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts index b41144ecd..ce1415a62 100644 --- a/ios-qa/daemon/test/daemon-integration.test.ts +++ b/ios-qa/daemon/test/daemon-integration.test.ts @@ -107,6 +107,57 @@ describe('daemon — loopback listener', () => { rmSync(workDir, { recursive: true, force: true }); }); + test('/auth/sessions returns salted-hash ids and metadata, never raw tokens', async () => { + const minted = daemon.tokenStore.mint({ + identity: 'owner@example.com', + capability: 'interact', + deviceUdid: 'STUB-UDID', + origin: 'owner_granted', + }); + if ('error' in minted) throw new Error(minted.error); + try { + const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`); + expect(r.status).toBe(200); + // The harvest-and-replay primitive: any local process could read live + // bearer tokens off this endpoint. The raw token must never appear. + expect(r.bodyText).not.toContain(minted.token); + + const { sessions } = JSON.parse(r.bodyText) as { sessions: Array> }; + const row = sessions.find(s => s.identity === 'owner@example.com'); + expect(row).toMatchObject({ + capability: 'interact', + device_udid: 'STUB-UDID', + origin: 'owner_granted', + expires_at: minted.expires_at, + }); + expect(row?.token_id).toMatch(/^[0-9a-f]{16}$/); + expect(row?.token).toBeUndefined(); + } finally { + daemon.tokenStore.revoke(minted.token); + } + }); + + test('revoke by token_id from the hash-only list still works (list→revoke)', async () => { + const minted = daemon.tokenStore.mint({ + identity: 'revoke-by-id@example.com', + capability: 'observe', + origin: 'owner_granted', + }); + if ('error' in minted) throw new Error(minted.error); + + const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`); + const { sessions } = JSON.parse(list.bodyText) as { sessions: Array> }; + const row = sessions.find(s => s.identity === 'revoke-by-id@example.com'); + expect(row?.token_id).toBeDefined(); + + const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, { + body: JSON.stringify({ token_id: row!.token_id }), + }); + expect(revoke.status).toBe(200); + expect(JSON.parse(revoke.bodyText).revoked).toBe(1); + expect(daemon.tokenStore.list().some(s => s.identity === 'revoke-by-id@example.com')).toBe(false); + }); + test('healthz returns 200 with mode=loopback', async () => { const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`); expect(r.status).toBe(200);