diff --git a/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md new file mode 100644 index 000000000..06f7e2cf0 --- /dev/null +++ b/devlog/_plan/260807_untouched_bug_stack/081_windows_acl_userdomain_adopt_1180.md @@ -0,0 +1,167 @@ +# 081 — #1149 재계획: 기여자 PR #1180 채택 + 개선 + +`080` 은 우리가 처음부터 구현하는 전제로 썼다. 그 사이 기여자 PR +[#1180](https://github.com/lidge-jun/opencodex/pull/1180) (`luvs01`, +`agent/fix-windows-acl-effective-sid`, head `df6989c17`) 이 같은 결함을 거의 +같은 설계로 이미 고쳐놨다. 처음부터 다시 쓰는 것은 기여자 저작을 버리는 +행위이고, 우리 계획이 요구한 제약을 그 PR 이 대부분 이미 만족한다. + +## #1180 이 080 의 제약을 어디까지 지켰나 + +| 080 제약 | #1180 | +|---|---| +| `whoami` 신규 작성 금지 | 지킴 — `[WindowsIdentity]::GetCurrent().User.Value` | +| 제3의 System32 리졸버 금지 | 지킴 — `resolveTrustedWindowsPowerShellExe()` 재사용 | +| sync/async 양쪽 | 지킴 — `resolveCurrentWindowsPrincipal{,Async}` | +| SID 타임아웃이 `timedOutPaths` 오염 금지 | 지킴 — 별도 코드 `EACLIDENTITY` | +| 성공만 캐시 | 지킴 — `principalFromResult` 통과 후에만 `cachedPrincipal` | +| harden 예산에서 차감 | 부분 — 남은 예산을 자식 timeout 으로 넘기지만, 실행 파일 리졸브와 spawn 준비는 그 timeout 이 시작되기 전에 일어난다 (아래 D) | +| `required:true` fail-closed | 지킴 | + +`user-identity.ts` 를 직접 재사용하는 대신 저수준 프리미티브를 새로 뽑은 것도 +`080` 의 "extract a neutral primitive" 와 같은 결론이다. 그쪽은 도메인 전용 +예외를 던지고, 무자격 `powershell.exe` 를 띄우며, 타임아웃도 `windowsHide` 도 +없고, 동기 전용이다. + +## 감사에서 뒤집힌 것 — 폴백 복원안 철회 + +이 문서의 첫 판은 optional read path 에 `USERDOMAIN\USERNAME` 폴백을 복원하자고 +했다. 독립 감사가 P1 으로 되돌렸고, 그 논증이 옳다. + +`DOMAIN\User` 라는 **형태**는 그 계정이 현재 토큰의 주체라는 **증거가 아니다**. +두 환경변수 모두 우리를 띄운 프로세스가 쓸 수 있다. 그리고 optional 경로도 +`required` 와 똑같은 파괴적 시퀀스를 돈다: + +``` +/grant:r :(F) ← 이 시점에 잘못된 계정이 Full Control 을 얻는다 +/inheritance:r ← 상속 ACE 를 전부 끊는다 +/remove:g ← Everyone/Users/Authenticated Users 만 지운다 +``` + +공격자가 고른 이름이 다른 실제 사용자로 해석되면 그 사용자의 ACE 가 시크릿에 +남고, 현재 사용자는 방금 끊긴 상속 접근을 잃는다. 고른 이름이 `BUILTIN\Users` +로 해석되면 3단계가 방금 만든 ACE 를 지워서 파일이 접근 불가가 된다. + +"optional 은 status quo 라서 안전하다" 는 논증은 성립하지 않는다. status quo 가 +안전했던 게 아니라, status quo 가 바로 #1149 가 신고한 결함이다. + +**따라서 optional SID 실패는 icacls 를 한 번도 실행하지 않고 끝낸다** — #1180 의 +동작 그대로다. 이름 폴백이 언젠가 필요하다면 환경변수가 아니라 토큰 SID 를 OS +의 신뢰된 API 로 이름 변환하는 별도 권위 경로여야 하고, 그건 이 유닛의 범위가 +아니다. + +## 우리가 얹는 것 + +### (A) 테스트 전용 상수가 프로덕션 파일 한가운데 있다 + +```ts +const FORCED_NON_WINDOWS_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + +function currentWindowsPrincipal(deadline: number): string { + if (platformOverride === "win32" && platform !== "win32") { + return FORCED_NON_WINDOWS_TEST_PRINCIPAL; + } + ... +``` + +**이것은 보안 결함이 아니다.** 감사가 정확히 지적한 대로, 프로덕션 Windows 에서는 +`platform !== "win32"` 가 거짓이라 이 분기에 도달할 수 없고, POSIX 에서도 +테스트 전용 setter 를 호출해야 켜진다. 위생 문제이며, 그 이상으로 포장하지 않는다. + +옮기는 진짜 이유는 (B) 다. 합성값이 프로덕션 모듈에 있는 한 실패 주입이 불가능하다. + +이 분기가 필요한 이유 자체는 실재한다. POSIX CI 는 `setPlatformForTests("win32")` +로 ACL 분기를 강제로 돌리는데, 그 호스트에는 PowerShell 도 System32 도 없다. +이미 그렇게 도는 테스트가 7개 파일 30여 곳이다. + +**해결:** 합성 SID 를 `windows-user-principal.ts` 의 테스트 seam 으로 옮기고, +`setPlatformForTests` 가 그 seam 을 켜고 끈다. `windows-secret-acl.ts` 에는 +`FORCED_NON_WINDOWS_TEST_PRINCIPAL` 상수도, 그것을 고르는 분기도 남지 않는다. + +### (B) 실패 경로 테스트가 POSIX CI 에서 통째로 스킵된다 + +```ts +test("identity lookup failure is fail-closed but never memoized as an icacls timeout", () => { + if (process.platform !== "win32") return; +``` + +`timedOutPaths` 오염 금지는 `080` 이 명시적으로 요구한 제약인데, 그것을 지키는 +유일한 테스트가 Linux/macOS 러너에서 한 줄도 실행되지 않는다. 원인은 (A) 다 — +합성 principal 이 runner 보다 먼저 반환하므로 POSIX 에서는 실패를 주입할 방법이 +없었다. + +**해결:** 어느 runner 를 쓸지 고를 때 명시적 override 가 합성값을 이기게 한다. + +``` +runner 선택: explicit override > synthetic(test) > default +성공 캐시: 선택된 경로와 무관하게 그대로 authoritative +``` + +"override 가 캐시보다 먼저" 라는 뜻이 아니다 — 성공한 조회는 여전히 캐시되고 +재사용된다. 바뀌는 것은 캐시가 비어 있을 때 **무엇을 실행하느냐** 뿐이다. +그러면 실패 주입 테스트가 세 플랫폼 전부에서 돈다. 스킵 가드를 제거한다. + +### (C) `required` 경계에서 `EACLIDENTITY` 코드가 소실된다 + +`sanitizedAclError` (`src/lib/windows-secret-acl.ts:557-566`) 는 허용 목록에 든 +코드만 재부착한다: + +```ts +if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + error.code = code; +} +``` + +`EACLIDENTITY` 가 없다. #1180 은 `sanitizeDiagnostics` 에는 케이스를 추가했으므로 +**메시지 문자열**에는 남지만, `required: true` 가 던지는 오류의 `error.code` 는 +`undefined` 다. 호출자가 원인을 프로그램적으로 구분할 수 없다. + +#1180 의 테스트가 이걸 가린다: `.toThrow(/EACLIDENTITY/)` 는 메시지만 본다. + +**해결:** 허용 목록에 `EACLIDENTITY` 를 추가하고, 테스트를 코드 검사로 바꾼다. + +### (D) 예산 caveat 을 문서로 정직하게 남긴다 + +`080` 은 "lookup 을 harden 예산에 차감" 을 요구했다. #1180 은 남은 예산을 자식 +프로세스 timeout 으로 넘기지만, 그 timeout 이 시작되기 전에 두 가지가 일어난다: +`resolveTrustedWindowsPowerShellExe()` 의 `GetSystemDirectoryW` FFI 호출, 그리고 +`Bun.spawn` 반환 이후에야 걸리는 async 타이머. + +통상 작지만 hard bound 는 아니다. 남는 위험은 잘못된 권한 부여가 아니라 — +두 작업 모두 ACL 이 바뀌기 전에 끝난다 — 예산을 조금 넘길 수 있는 가용성 +문제다. 강제하려면 runner 계약과 동기 실행 모델까지 손대야 해서 채택 개선과 +분리한다. 대신 `windows-user-principal.ts` 상단에 caveat 을 명시해서, 다음에 이 +예산을 조이는 사람이 착각하지 않게 한다. + +## 변경 파일 + +- `src/lib/windows-user-principal.ts` — 합성 seam 추가, override 우선순위, 예산 caveat +- `src/lib/windows-secret-acl.ts` — 합성 상수/분기 제거, `EACLIDENTITY` 허용 목록 추가 +- `tests/windows-user-principal.test.ts` — override 우선순위 케이스 +- `tests/windows-secret-acl.test.ts` — 스킵 가드 제거, sync/async × required/optional 행렬 + +## 수용 기준 + +1. `windows-secret-acl.ts` 전체에 `FORCED_NON_WINDOWS_TEST_PRINCIPAL` 문자열도, + 합성 principal 을 고르는 `platformOverride` 분기도 없다 (`rg` 로 확인 가능). +2. SID 실패 + `required: true` → 던져진 오류가 `toMatchObject({ code: "EACLIDENTITY" })` + 를 만족하고, `timedOutSecretPathCountForTests() === 0`, icacls 호출 0회. + **POSIX 러너에서 실제로 실행된다** (스킵 가드 없음). +3. SID 실패 + `required: false` → `{ ok: false, diagnostics }` 반환, icacls 호출 0회, + ACL 변경 없음. 환경변수 폴백 없음. +4. 2·3 이 sync (`hardenSecretPath`) 와 async (`hardenSecretPathAsync`) 양쪽에 + 동일하게 성립한다. +5. ablation — 각각 되돌렸을 때 red 가 되는 테스트를 명시한다: + - (A)+(B) 우선순위를 `synthetic → override` 로 되돌리면: 주입한 실패 runner 가 + 호출되지 않아 `identityCalls === 0` 이 되고, required 하든이 성공해버려 + 기준 2 가 **red**. + - (C) 허용 목록에서 `EACLIDENTITY` 를 빼면: `error.code` 가 `undefined` 가 되어 + 기준 2 의 `toMatchObject` 가 **red**. + - 철회한 환경변수 폴백을 되살리면: 기준 3 의 icacls 호출 0회 assertion 이 + **red**. 이 mutation 을 명시해 두는 이유는, 폴백 철회가 이 유닛에서 가장 + 되돌아오기 쉬운 결정이기 때문이다. + +## 커밋 구성 + +기여자 커밋 `df6989c17` 을 cherry-pick 해서 저작을 보존하고, 그 위에 개선 +커밋을 얹는다. #1180 은 대체 PR 번호를 남기고 close 한다. diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index 9549d3987..0ac11927a 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -31,6 +31,11 @@ import { existsSync, statSync } from "node:fs"; import { env, platform } from "node:process"; +import { + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, + setSyntheticWindowsPrincipalForTests, +} from "./windows-user-principal"; const hardenedDirectories = new Map(); const hardenedPaths = new Map(); @@ -336,9 +341,21 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null): asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner; } -/** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */ +/** + * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. + * + * Faking win32 on a host without System32 also has to supply a principal, or + * every forced-branch test would fail on the identity lookup instead of + * exercising icacls. The synthetic value is registered with the resolver, not + * chosen here, so a test that injects its own runner still wins. + */ +const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001"; + export function setPlatformForTests(value: string | null): void { platformOverride = value; + setSyntheticWindowsPrincipalForTests( + value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null, + ); } /** Test seam: injectable clock for deadline tests (no real sleeps). */ @@ -408,17 +425,26 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException } /** - * Return the current Windows username from the environment. - * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous. - * The value is used directly in icacls arguments, so it must be present. + * The ACL principal is the effective token SID and nothing else. + * + * There is no name-shaped fallback here, and that absence is the fix for #1149 + * rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not + * evidence of the current token's subject, and both variables are writable by + * the process that launched us. Granting Full Control to a wrong principal and + * then running `/inheritance:r` is destructive in both directions: another + * account can be left holding the secret, or the file can be left with no ACE + * the current user can use. When the SID cannot be resolved we decline. + * + * Non-Windows hosts that force this branch through `setPlatformForTests` get + * their principal from `setSyntheticWindowsPrincipalForTests`, which lives with + * the resolver so an injected runner can still take precedence over it. */ -function currentWindowsUser(): string | undefined { - const username = env["USERNAME"]; - const domain = env["USERDOMAIN"]; - if (!username) return undefined; - // USERDOMAIN is the machine/domain name; USERNAME is the account name. - // icacls accepts "DOMAIN\User" or just "User" for local accounts. - return domain ? `${domain}\\${username}` : username; +function currentWindowsPrincipal(deadline: number): string { + return resolveCurrentWindowsPrincipal(deadline - nowFn()); +} + +async function currentWindowsPrincipalAsync(deadline: number): Promise { + return resolveCurrentWindowsPrincipalAsync(deadline - nowFn()); } /** @@ -438,10 +464,7 @@ function grantAce(user: string, directory: boolean): string { } function runIcacls(targetPath: string, directory: boolean, deadline: number): void { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = currentWindowsPrincipal(deadline); // The deadline is owned by hardenEntry (total budget incl. retry + verification). const run = (step: string, args: string[]): IcaclsResult => { @@ -458,7 +481,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo // Step 1: grant current user full control BEFORE any destructive ACL change. // If this fails, inheritance is untouched and the writer keeps inherited access. - runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); // Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE // from step 1 survives this transition, so a later failure still leaves cleanup access. @@ -487,10 +510,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo /** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise { - const user = currentWindowsUser(); - if (!user) { - throw new Error("Cannot determine current Windows user for ACL hardening"); - } + const principal = await currentWindowsPrincipalAsync(deadline); const run = async (step: string, args: string[]): Promise => { const remaining = deadline - nowFn(); @@ -504,7 +524,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: if (!result.success) throw icaclsError(step, result); }; - await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(user, directory)]); + await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]); await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]); const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]); @@ -538,6 +558,8 @@ function sanitizeDiagnostics(error: unknown): string { return `ACL hardening failed (${code}) — permission denied running icacls`; case "EICACLS": return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs"; + case "EACLIDENTITY": + return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; default: return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`; } @@ -554,7 +576,17 @@ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoExc const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : ""; - if (code === "ETIMEDOUT" || code === "EICACLS" || code === "EACCES" || code === "EPERM") { + // EACLIDENTITY belongs here for the same reason as the rest: a caller that + // catches a required-mode failure has to tell "the SID could not be resolved" + // apart from "icacls stalled". Without it the code was dropped and only the + // message carried the cause, which no caller can branch on. + if ( + code === "ETIMEDOUT" || + code === "EICACLS" || + code === "EACCES" || + code === "EPERM" || + code === "EACLIDENTITY" + ) { error.code = code; } return error; diff --git a/src/lib/windows-user-principal.ts b/src/lib/windows-user-principal.ts new file mode 100644 index 000000000..93da3efd5 --- /dev/null +++ b/src/lib/windows-user-principal.ts @@ -0,0 +1,283 @@ +/** + * Resolve the effective Windows token to the locale-independent SID form that + * icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not + * an authority for the current token: on workgroup machines USERDOMAIN may be + * the literal WORKGROUP even though the account belongs to the local computer. + * + * There is deliberately NO name-shaped fallback. A `DOMAIN\User` string has a + * valid shape, but shape is not evidence that the account is the current + * token's subject, and both environment variables are writable by whatever + * launched us. A wrong principal here is not cosmetic: `runIcacls` grants it + * Full Control and then removes inheritance, so a wrong grant either leaves a + * different account holding the secret or strands the file with no usable ACE. + * When the SID cannot be resolved, the caller declines to touch the ACL at all. + * + * Budget caveat: callers pass their REMAINING harden budget, which becomes the + * child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW` + * FFI call) and spawn setup happen before that timeout starts, and the async + * timer only arms once `Bun.spawn` returns. Both are small in practice, but the + * lookup is not bounded by the deadline to the microsecond. Tightening that + * would mean passing an absolute deadline through the runner interface. + */ + +import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation"; + +const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i; +const SID_EXPRESSION = + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value"; + +export interface WindowsPrincipalLookupResult { + success: boolean; + exitCode: number | null; + timedOut: boolean; + stdout: string; +} + +export type WindowsPrincipalRunner = ( + timeoutMs: number, +) => WindowsPrincipalLookupResult; + +export type AsyncWindowsPrincipalRunner = ( + timeoutMs: number, +) => Promise; + +const POWERSHELL_ARGS = [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + SID_EXPRESSION, +] as const; + +function windowsPrincipalPowerShellCommand(): string[] { + return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS]; +} + +/** Test-only readback of the exact trusted executable and static arguments. */ +export function windowsPrincipalPowerShellCommandForTests(): string[] { + return windowsPrincipalPowerShellCommand(); +} + +function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult { + const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: Math.max(1, timeoutMs), + windowsHide: true, + }); + return { + success: result.success, + exitCode: result.exitCode, + timedOut: result.exitedDueToTimeout ?? false, + stdout: result.stdout ? result.stdout.toString() : "", + }; +} + +async function defaultAsyncWindowsPrincipalRunner( + timeoutMs: number, +): Promise { + const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + windowsHide: true, + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { proc.kill(); } catch { /* already exited */ } + }, Math.max(1, timeoutMs)); + let exitCode: number | null = null; + try { + exitCode = await proc.exited; + } finally { + clearTimeout(timer); + } + const stdout = proc.stdout + ? await new Response(proc.stdout).text().catch(() => "") + : ""; + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? null : exitCode, + timedOut, + stdout, + }; +} + +let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner; +let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner; +let cachedPrincipal: string | null = null; +let asyncLookupInFlight: Promise | null = null; + +/** + * POSIX CI drives the Windows ACL branch through `setPlatformForTests("win32")`, + * on hosts that have neither System32 nor PowerShell. Those runs need SOME + * principal, so this seam supplies a synthetic one. + * + * It lives here rather than in `windows-secret-acl.ts` for one reason that is + * not cosmetic: an explicitly injected runner must be able to beat it. When the + * synthetic value was chosen first, in the ACL module, a test could not inject a + * lookup FAILURE on POSIX at all — so the fail-closed and memo-isolation cases + * were guarded with `if (process.platform !== "win32") return;` and never ran + * outside Windows. Resolution order below is what makes those cases executable + * on every runner. + */ +let syntheticPrincipalForTests: string | null = null; + +/** + * Test seam: supply the principal used when no runner was injected and the host + * is not really Windows. Pass null to disable. + */ +export function setSyntheticWindowsPrincipalForTests(principal: string | null): void { + syntheticPrincipalForTests = principal; + cachedPrincipal = null; +} + +/** True when an explicit runner override is installed and must take precedence. */ +function hasSyncRunnerOverride(): boolean { + return principalRunner !== defaultWindowsPrincipalRunner; +} + +function hasAsyncRunnerOverride(): boolean { + return asyncPrincipalRunner !== defaultAsyncWindowsPrincipalRunner; +} + +function identityError(reason: string): NodeJS.ErrnoException { + const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException; + // Keep identity lookup failures distinct from icacls timeouts. In particular, + // they must not populate windows-secret-acl's destination timeout memo. + error.code = "EACLIDENTITY"; + return error; +} + +function principalFromResult(result: WindowsPrincipalLookupResult): string { + if (!result.success) { + throw identityError(result.timedOut + ? "timed out" + : `exited ${result.exitCode ?? "null"}`); + } + const sid = result.stdout.trim(); + if (!SID_PATTERN.test(sid)) { + throw identityError(sid ? "returned an invalid SID" : "returned an empty SID"); + } + return `*${sid.toUpperCase()}`; +} + +/** Resolve and process-cache the effective token SID for synchronous ACL paths. */ +export function resolveCurrentWindowsPrincipal(timeoutMs: number): string { + // Order matters: an explicitly injected runner outranks the synthetic value, + // so a test can inject a FAILURE on a POSIX host. See the seam comment above. + if (hasSyncRunnerOverride()) { + if (cachedPrincipal) return cachedPrincipal; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let overridden: WindowsPrincipalLookupResult; + try { + overridden = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(overridden); + cachedPrincipal = principal; + return principal; + } + if (cachedPrincipal) return cachedPrincipal; + if (syntheticPrincipalForTests) return syntheticPrincipalForTests; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let result: WindowsPrincipalLookupResult; + try { + result = principalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; +} + +async function waitForExistingLookup( + lookup: Promise, + timeoutMs: number, +): Promise { + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + lookup, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(identityError("timed out while awaiting the shared lookup")), + Math.max(1, timeoutMs), + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +/** + * Async counterpart. Concurrent callers share one owned child lookup; a later + * caller may exhaust its own budget without cancelling the lookup owned by the + * first caller. The first caller owns that child and its process timeout; a + * later, longer budget deliberately does not extend an already-running child. + */ +export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise { + const overridden = hasAsyncRunnerOverride(); + if (cachedPrincipal) return cachedPrincipal; + if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs); + // Same precedence rule as the sync path: an injected runner beats the synthetic. + if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests; + if (timeoutMs <= 0) throw identityError("had no remaining deadline"); + + const lookup = (async (): Promise => { + let result: WindowsPrincipalLookupResult; + try { + result = await asyncPrincipalRunner(timeoutMs); + } catch { + throw identityError("could not start"); + } + const principal = principalFromResult(result); + cachedPrincipal = principal; + return principal; + })(); + asyncLookupInFlight = lookup; + try { + return await lookup; + } finally { + if (asyncLookupInFlight === lookup) asyncLookupInFlight = null; + } +} + +/** Test seam: replace the sync resolver process and clear its successful cache. */ +export function setWindowsPrincipalRunnerForTests( + runner: WindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + principalRunner = runner ?? defaultWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: replace the async resolver process and clear its successful cache. */ +export function setAsyncWindowsPrincipalRunnerForTests( + runner: AsyncWindowsPrincipalRunner | null, +): void { + if (asyncLookupInFlight) { + throw new Error("Cannot replace the Windows principal runner while a lookup is in flight."); + } + asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner; + cachedPrincipal = null; +} + +/** Test seam: clear only process-local principal state. */ +export function resetWindowsPrincipalForTests(): void { + if (asyncLookupInFlight) { + throw new Error("Cannot reset the Windows principal while a lookup is in flight."); + } + cachedPrincipal = null; + syntheticPrincipalForTests = null; +} diff --git a/tests/windows-secret-acl.test.ts b/tests/windows-secret-acl.test.ts index 9ac911a14..77580d239 100644 --- a/tests/windows-secret-acl.test.ts +++ b/tests/windows-secret-acl.test.ts @@ -36,6 +36,11 @@ import { atomicWriteFile } from "../src/config"; import { hardenStableLockFile } from "../src/codex/native-main-lock-file"; import { nativeMainClaimPath, withNativeMainSharedClaim } from "../src/codex/native-main-claim"; import { NATIVE_MAIN_OWNER_DB, retainNativeMainOwner } from "../src/codex/native-main-owner"; +import { + resetWindowsPrincipalForTests, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, +} from "../src/lib/windows-user-principal"; let testDir = ""; @@ -126,6 +131,181 @@ describe("hardenSecretPath – required mode (required: true)", () => { }); }); +describe("effective Windows principal integration", () => { + test("the owner grant uses a numeric SID even when USERDOMAIN says WORKGROUP", () => { + const filePath = join(testDir, "workgroup-secret.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-authoritative"; + resetHardenedStateForTests(); + setPlatformForTests("win32"); + const seen: string[][] = []; + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + try { + expect(hardenSecretPath(filePath, { required: true })).toEqual({ ok: true }); + const grant = seen.find(args => args.includes("/grant:r")); + expect(grant).toBeDefined(); + expect(grant![2]).toMatch(/^\*S-1-(?:\d+-)+\d+:\(F\)$/i); + expect(grant![2]).not.toContain("WORKGROUP"); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + // These ran only on Windows until the resolver learned to let an injected + // runner outrank the synthetic POSIX principal. A case that silently returns + // on two of three CI platforms is not coverage of a fail-closed boundary, and + // the timedOutPaths isolation it asserts is the whole reason the identity + // failure carries its own error code. + const IDENTITY_DIAGNOSTIC = + "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved"; + + test("a failed identity lookup fails closed, runs no icacls, and never enters the timeout memo", () => { + const requiredPath = join(testDir, "identity-required.json"); + const optionalPath = join(testDir, "identity-optional.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setWindowsPrincipalRunnerForTests(() => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setIcaclsRunnerForTests(() => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + // required: throws, and the CODE survives sanitization so a caller can + // branch on the cause rather than parse the message. + let thrown: NodeJS.ErrnoException | undefined; + try { + hardenSecretPath(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + // optional: soft-fails WITHOUT touching the ACL. No name-shaped fallback. + expect(hardenSecretPath(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + + // The injected runner actually ran — this is what regressed to 0 when the + // synthetic principal was chosen first. + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + // An identity failure is not an icacls timeout: the path stays retryable. + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); + + test("the async harden path applies the same identity policy", async () => { + const requiredPath = join(testDir, "identity-required-async.json"); + const optionalPath = join(testDir, "identity-optional-async.json"); + writeFileSync(requiredPath, "required", "utf-8"); + writeFileSync(optionalPath, "optional", "utf-8"); + let identityCalls = 0; + let icaclsCalls = 0; + setAsyncWindowsPrincipalRunnerForTests(async () => { + identityCalls += 1; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + setAsyncIcaclsRunnerForTests(async () => { + icaclsCalls += 1; + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + let thrown: NodeJS.ErrnoException | undefined; + try { + await hardenSecretPathAsync(requiredPath, { required: true }); + } catch (error) { + thrown = error as NodeJS.ErrnoException; + } + expect(thrown).toBeDefined(); + expect(thrown).toMatchObject({ code: "EACLIDENTITY" }); + + expect(await hardenSecretPathAsync(optionalPath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + + expect(identityCalls).toBe(2); + expect(icaclsCalls).toBe(0); + expect(timedOutSecretPathCountForTests()).toBe(0); + } finally { + setAsyncIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } + }); + + test("no name-shaped principal reaches icacls when the environment names a plausible account", () => { + const filePath = join(testDir, "no-name-fallback.json"); + writeFileSync(filePath, "data", "utf-8"); + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + // A shape a reader would accept at a glance. It is still not the token. + process.env.USERDOMAIN = "CORP"; + process.env.USERNAME = "administrator"; + const seen: string[][] = []; + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: 1, + timedOut: false, + stdout: "", + })); + setIcaclsRunnerForTests(args => { + seen.push(args); + return { success: true, exitCode: 0, timedOut: false, stdout: "" }; + }); + resetHardenedStateForTests(); + setPlatformForTests("win32"); + try { + expect(hardenSecretPath(filePath, { required: false })).toEqual({ + ok: false, + diagnostics: IDENTITY_DIAGNOSTIC, + }); + expect(seen).toEqual([]); + } finally { + setIcaclsRunnerForTests(null); + setPlatformForTests(null); + resetHardenedStateForTests(); + setWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); +}); + describe("ephemeral harden success memo lifecycle", () => { test("forgetHardenedSecretPath releases only the actual temp and a second temp hardens again", () => { // Earlier cases in this file harden real paths under the win32 override and diff --git a/tests/windows-user-principal.test.ts b/tests/windows-user-principal.test.ts new file mode 100644 index 000000000..c0d01cc57 --- /dev/null +++ b/tests/windows-user-principal.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + resetWindowsPrincipalForTests, + resolveCurrentWindowsPrincipal, + resolveCurrentWindowsPrincipalAsync, + setAsyncWindowsPrincipalRunnerForTests, + setWindowsPrincipalRunnerForTests, + windowsPrincipalPowerShellCommandForTests, +} from "../src/lib/windows-user-principal"; +import { setTrustedWindowsElevationExecutablesForTests } from "../src/lib/windows-elevation"; + +const ok = (stdout = "S-1-5-21-111-222-333-1001\r\n") => ({ + success: true, + exitCode: 0, + timedOut: false, + stdout, +}); + +afterEach(() => { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + setTrustedWindowsElevationExecutablesForTests(null); + resetWindowsPrincipalForTests(); +}); + +describe("Windows effective ACL principal", () => { + test("builds a hidden non-interactive command from the trusted PowerShell path", () => { + const trusted = "C:\\trusted-system32\\WindowsPowerShell\\v1.0\\powershell.exe"; + setTrustedWindowsElevationExecutablesForTests({ powershell: trusted }); + expect(windowsPrincipalPowerShellCommandForTests()).toEqual([ + trusted, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ]); + }); + + test("the default trusted runner resolves the real token on Windows", () => { + if (process.platform !== "win32") return; + expect(resolveCurrentWindowsPrincipal(5_000)).toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("the default trusted async runner settles and resolves the real token on Windows", async () => { + if (process.platform !== "win32") return; + expect(await resolveCurrentWindowsPrincipalAsync(5_000)) + .toMatch(/^\*S-1-(?:\d+-)+\d+$/i); + }); + + test("uses the token SID and normalizes it for icacls, independent of WORKGROUP env", () => { + const oldDomain = process.env.USERDOMAIN; + const oldUser = process.env.USERNAME; + process.env.USERDOMAIN = "WORKGROUP"; + process.env.USERNAME = "not-the-token-authority"; + setWindowsPrincipalRunnerForTests(() => ok()); + try { + expect(resolveCurrentWindowsPrincipal(1_000)).toBe("*S-1-5-21-111-222-333-1001"); + } finally { + if (oldDomain === undefined) delete process.env.USERDOMAIN; + else process.env.USERDOMAIN = oldDomain; + if (oldUser === undefined) delete process.env.USERNAME; + else process.env.USERNAME = oldUser; + } + }); + + test("caches only a successful lookup", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok(); + }); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(resolveCurrentWindowsPrincipal(1_000)).toMatch(/^\*S-1-/); + expect(calls).toBe(1); + }); + + test("invalid output fails closed and is retried rather than cached", () => { + let calls = 0; + setWindowsPrincipalRunnerForTests(() => { + calls += 1; + return ok("WORKGROUP\\user\n"); + }); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + } + expect(calls).toBe(2); + }); + + test("a resolver timeout stays EACLIDENTITY rather than entering the icacls timeout class", () => { + setWindowsPrincipalRunnerForTests(() => ({ + success: false, + exitCode: null, + timedOut: true, + stdout: "", + })); + try { + resolveCurrentWindowsPrincipal(1_000); + throw new Error("expected identity refusal"); + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe("EACLIDENTITY"); + } + }); + + test("concurrent async callers share one owned lookup", async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + setAsyncWindowsPrincipalRunnerForTests(async () => { + calls += 1; + await gate; + return ok(); + }); + + const first = resolveCurrentWindowsPrincipalAsync(2_000); + const second = resolveCurrentWindowsPrincipalAsync(2_000); + await Bun.sleep(0); + expect(calls).toBe(1); + release(); + await expect(first).resolves.toBe("*S-1-5-21-111-222-333-1001"); + await expect(second).resolves.toBe("*S-1-5-21-111-222-333-1001"); + }); +});