diff --git a/utils/gitAuth.ts b/utils/gitAuth.ts index 20b27ad..a4f9e87 100644 --- a/utils/gitAuth.ts +++ b/utils/gitAuth.ts @@ -1,9 +1,14 @@ /** * git authentication via GIT_ASKPASS. * - * a localhost HTTP server serves tokens via single-use UUID codes. - * each $git() call writes a unique askpass script with the server - * port+code baked into the file body — no secrets in subprocess env. + * a localhost HTTP server serves tokens via UUID codes whose lifetime is + * bounded by the parent $git() invocation: register() makes the code active, + * the script (and any sibling subprocess — e.g. git-lfs pre-push) can fetch + * the token any number of times, and $git()'s finally calls revoke() to + * close the window. each $git() call writes a unique askpass script with + * the server port+code baked into the file body — no secrets in subprocess + * env. a replay of a revoked code trips a 409 and revokes the underlying + * github installation token. * * see wiki/askpass.md for full security documentation. */ @@ -88,9 +93,13 @@ export function setGitAuthServer(server: GitAuthServer): void { * a remote and need credentials. working-tree operations (checkout, merge) * use $() from shell.ts which has no token. * - * per call: registers a one-time code with the auth server, writes a - * unique askpass script with port+code baked in, spawns git with - * GIT_ASKPASS pointing to the script, and deletes the script in finally. + * per call: registers a code with the auth server (valid for the lifetime + * of this invocation), writes a unique askpass script with port+code baked + * in, spawns git with GIT_ASKPASS pointing to the script. on completion, + * revokes the code and deletes the script in finally. multiple sibling + * askpass calls within one invocation (e.g. git itself + git-lfs pre-push) + * all see a valid code; replay attempts after finally trip a 409 and the + * server revokes the underlying github token as a tamper signal. * * @example * await $git("fetch", ["origin", "main"], { token }); @@ -149,8 +158,8 @@ export async function $git( }); if (result.stderr.includes("askpass-compromised")) { - log.info("askpass code was already consumed — token has been revoked"); - throw new Error("git auth failed — askpass code was already consumed, token revoked"); + log.info("askpass code was replayed after revoke — token has been revoked"); + throw new Error("git auth failed — askpass code was replayed after revoke, token revoked"); } if (result.exitCode !== 0) { @@ -175,10 +184,11 @@ export async function $git( stderr: result.stderr.trim(), }; } finally { + authServer.revoke(code); try { unlinkSync(scriptPath); } catch { - // script may have self-deleted already + // script may already be gone (e.g. tmpdir cleanup raced us) } } } diff --git a/utils/gitAuthServer.test.ts b/utils/gitAuthServer.test.ts index 997a254..680e760 100644 --- a/utils/gitAuthServer.test.ts +++ b/utils/gitAuthServer.test.ts @@ -75,8 +75,23 @@ describe("token delivery", () => { }); }); -describe("single-use enforcement (tamper detection)", () => { - it("returns 409 on second use of same code", async () => { +describe("code lifecycle (tamper detection)", () => { + it("returns the token on repeated use while the code is active", async () => { + // a single $git() call can produce multiple legitimate askpass requests: + // git itself (username + password), git-lfs pre-push hook, custom hooks. + // they must all succeed until $git()'s finally calls revoke(). + const tmp = makeTmpdir(); + server = await startGitAuthServer(tmp); + const code = server.register("ghs_active_test"); + + for (let i = 0; i < 5; i++) { + const res = await fetch(`http://127.0.0.1:${server.port}/${code}`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ghs_active_test"); + } + }); + + it("returns 409 after revoke (replay-after-call trap)", async () => { const tmp = makeTmpdir(); server = await startGitAuthServer(tmp); const code = server.register("ghs_tamper_test"); @@ -84,10 +99,17 @@ describe("single-use enforcement (tamper detection)", () => { const first = await fetch(`http://127.0.0.1:${server.port}/${code}`); expect(first.status).toBe(200); - const second = await fetch(`http://127.0.0.1:${server.port}/${code}`); - expect(second.status).toBe(409); - const body = await second.text(); - expect(body).toBe("compromised"); + server.revoke(code); + + const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`); + expect(replay.status).toBe(409); + expect(await replay.text()).toBe("compromised"); + }); + + it("revoke() on an unknown code is a no-op", async () => { + const tmp = makeTmpdir(); + server = await startGitAuthServer(tmp); + expect(() => server!.revoke("nonexistent")).not.toThrow(); }); it("each register() call produces an independent code", async () => { diff --git a/utils/gitAuthServer.ts b/utils/gitAuthServer.ts index f955807..6edf1cf 100644 --- a/utils/gitAuthServer.ts +++ b/utils/gitAuthServer.ts @@ -1,12 +1,17 @@ /** * ASKPASS-based git authentication server. * - * serves tokens via a localhost HTTP server with single-use UUID codes. + * serves tokens via a localhost HTTP server with per-$git()-call UUID codes. * each $git() call gets a unique askpass script with the port+code baked in. * the token never appears in subprocess env — only the script file path. * - * tamper-evident: if a code is used twice, the second request triggers - * immediate token revocation via the GitHub API as a precaution. + * lifetime: the code is valid for as long as the $git() invocation is + * running. multiple askpass calls within one invocation (e.g. git's own + * fetch/push + a git-lfs pre-push hook that also authenticates) all + * succeed. $git() calls revoke(code) in finally; subsequent requests for + * a revoked code trigger immediate token revocation via the GitHub API + * as a tamper-evidence precaution (an agent replaying the code after the + * legitimate window has closed is the realistic attack we still catch). */ import { randomUUID } from "node:crypto"; @@ -15,20 +20,25 @@ import { createServer } from "node:http"; import { join } from "node:path"; import { log } from "./cli.ts"; -type CodeState = "pending" | "consumed"; +type CodeState = "active" | "revoked"; -type PendingCode = { +type CodeEntry = { token: string; state: CodeState; - timeout: NodeJS.Timeout; + // only present once the entry is revoked — bounds the replay-trap window. + // active entries have no timer because $git() can take arbitrarily long + // (large LFS pushes, slow networks, `activityTimeout: 0` on the spawn); + // any wall-clock TTL here would re-introduce the original LFS bug at + // a different boundary. revoke() is the only way out for an active code. + timeout?: NodeJS.Timeout; }; -const CODE_TTL_MS = 5 * 60 * 1000; -const TAMPER_WINDOW_MS = 60_000; +const REVOKED_TRAP_MS = 60_000; export type GitAuthServer = { port: number; register: (token: string) => string; + revoke: (code: string) => void; writeAskpassScript: (code: string) => string; close: () => Promise; [Symbol.asyncDispose]: () => Promise; @@ -49,7 +59,7 @@ function revokeGitHubToken(token: string): void { } export async function startGitAuthServer(tmpdir: string): Promise { - const codes = new Map(); + const codes = new Map(); const server = createServer((req, res) => { if (req.method !== "GET") { @@ -69,21 +79,20 @@ export async function startGitAuthServer(tmpdir: string): Promise return; } - if (entry.state === "pending") { - // first use — return token, keep entry for tamper detection - entry.state = "consumed"; - clearTimeout(entry.timeout); - entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS); - entry.timeout.unref(); + if (entry.state === "active") { + // legitimate caller (git, git-lfs, or any subprocess of the running + // $git() call). hand back the token without consuming the code — + // revoke() in $git's finally is what closes the window. res.writeHead(200, { "Content-Type": "text/plain" }); res.end(entry.token); return; } - // second request for same code — revoke token as a precaution - log.info("askpass code used twice — revoking token"); + // request for a revoked code — the $git() window has closed, so this + // is an agent replaying the code. revoke the token as a precaution. + log.info("askpass code used after revoke — revoking token"); revokeGitHubToken(entry.token); - clearTimeout(entry.timeout); + if (entry.timeout) clearTimeout(entry.timeout); codes.delete(code); res.writeHead(409, { "Content-Type": "text/plain" }); res.end("compromised"); @@ -104,25 +113,34 @@ export async function startGitAuthServer(tmpdir: string): Promise function register(token: string): string { const code = randomUUID(); - const timeout = setTimeout(() => { - codes.delete(code); - log.debug(`git auth code expired: ${code.slice(0, 8)}...`); - }, CODE_TTL_MS); - timeout.unref(); - codes.set(code, { token, state: "pending", timeout }); + codes.set(code, { token, state: "active" }); return code; } + function revoke(code: string): void { + const entry = codes.get(code); + if (!entry) return; + entry.state = "revoked"; + // keep the entry around briefly so a replay attempt trips the trap + // (token revocation) instead of returning an opaque 404. + entry.timeout = setTimeout(() => codes.delete(code), REVOKED_TRAP_MS); + entry.timeout.unref(); + } + function writeAskpassScript(code: string): string { const scriptId = randomUUID(); const scriptName = `askpass-${scriptId}.js`; const scriptPath = join(tmpdir, scriptName); // standalone node script — no project dependencies. - // git calls this twice: once for "Username for ..." and once for "Password for ...". - // username: return "x-access-token" locally (no server call). - // password: fetch token from auth server, self-delete, return token. - // 409 = code was already consumed by another process (tamper detected). + // git invokes this once per credential prompt — separate process spawn + // per prompt: one for "Username for ...", one for "Password for ...". + // sibling subprocesses (git-lfs pre-push, custom auth-bound hooks) + // invoke it independently for their own auth, also one spawn per prompt. + // all succeed as long as the parent $git() is still running, which is + // why neither the script nor the code is single-use. cleanup happens + // in $git()'s finally. + // 409 = code was already revoked by $git()'s finally (replay attempt). const content = [ `#!/usr/bin/env node`, `var a=process.argv[2]||"";`, @@ -132,10 +150,8 @@ export async function startGitAuthServer(tmpdir: string): Promise `if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`, `if(r.statusCode!==200){process.exit(1)}`, `var d="";r.on("data",function(c){d+=c});`, - `r.on("end",function(){`, - `process.stdout.write(d+"\\n");`, - `try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`, - `})}).on("error",function(){process.exit(1)})}`, + `r.on("end",function(){process.stdout.write(d+"\\n")})`, + `}).on("error",function(){process.exit(1)})}`, ].join("\n"); writeFileSync(scriptPath, content, { mode: 0o700 }); @@ -144,7 +160,7 @@ export async function startGitAuthServer(tmpdir: string): Promise async function close(): Promise { for (const entry of codes.values()) { - clearTimeout(entry.timeout); + if (entry.timeout) clearTimeout(entry.timeout); } codes.clear(); await new Promise((resolve) => server.close(() => resolve())); @@ -154,6 +170,7 @@ export async function startGitAuthServer(tmpdir: string): Promise return { port, register, + revoke, writeAskpassScript, close, [Symbol.asyncDispose]: close,