fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process

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 <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 12:35:47 -07:00
parent d2257abedd
commit f31aff1bc6
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 82 additions and 7 deletions

View File

@ -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<string> {
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<void> {
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,

View File

@ -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<void> {
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);
}

View File

@ -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<Record<string, unknown>> };
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<Record<string, unknown>> };
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);