Windows NodeVault keystore and capability
## Original requirement (verbatim)
> "Implement NodeVault on Windows and Mac"
Operator decisions that widen it (PRD §3, verbatim answers): D1 "Parity: owner-only file"; D2 "macOS arm64 + Windows x64"; D3 "Real nodes on dev"; D4 "Console should not explain why something can't. It must make it work so that everybody can"; D5 "Every node, all OSes"; D6 "One is enrolled already" (superseded for the Mac by Architect OD3: no Mac is on dev — Mac live checks deferred).
Architect decisions (settled, do not reopen): OD1 Linux self-heal on update; OD2 Windows widened ACL → repair and log (foreign owner / reparse point / hard link → refuse); AD1 no new uid-boundary mechanism; AD2 old-glibc/odd-arch Linux kinds are empty; AD3 macOS/Windows crypto bytes are `packaging/platform-pins.toml` bundle-time pins; AD4 `vendor_path.native_key()`; AD5/AD6 Windows explicit protected DACL via a `host_platform` seam; OD3 Mac live checks deferred.
Full inputs (read them): PRD = Task 9748's prd artifact; Architecture = Task 9749's architecture artifact (§ numbers below refer to it). Epic: Task 9750's parent.
## This child: C2 — Windows keystore and NodeVault capability
Requirements served (PRD §5, verbatim):
- **R2 — Windows x64 nodes are NodeVault nodes.** The same as R1 on Windows. The keystore is readable only by the daemon's service identity (plus SYSTEM/Administrators) per D1. The runner account and any other non-admin account are denied. The `secret_modes` statement "nothing on Windows" stays true for mode bits. The keystore's protection on Windows comes from its access list, checked by the daemon in the role `nodevault_store`'s owner/mode check plays on POSIX: fail closed when the access list is wider than allowed. (Amended by OD2: a widened grant is repaired and logged; foreign owner, reparse point or hard link are refused.)
- **R4 — Full NodeVault lifecycle on every OS.** Setup delivery, credential fetch, rotation, rekey, KMS→NodeVault migration, key retirement (removed from the node), operator identity reset, and org transfer (keys and keystore erased, ADR-0158) all behave on macOS and Windows as they do on Linux today. (Windows half here.)
- **R5 — Upgrade path.** A node that registered `capable = false` under an older daemon becomes capable after a Jaah update, with no operator reset. The CP then delivers the org's live keys automatically (`ensure_grants`). (No CP change needed — Architecture §2.1.)
- **R6 — Docs describe the present.** `docs/compliance/nodevault-key-handling.md` names each OS's keystore location, protection and owning principal. The ADR-0154 entry and `apps/jaahd/README.md` stop implying Linux-only. The context gate passes (`python3 tools/check_context.py`).
Depends on C1 (Windows runtime crypto). Code may be written in parallel but merges and is live-verified after C1 is merged and deployed.
### Approach (Architecture §2.3 — follow it exactly; the seam contract is pinned there)
- New module `apps/jaahd/nodevault_private.py` dispatching on `host_platform.is_windows()`: `create_dir(path)`, `create_file_excl(path) -> int`, `open_read(path) -> int | None`, `check(path, fd, *, is_dir)`. POSIX halves are today's `nodevault_store.py:100-107` and `:213-224` verbatim (same syscalls, same order). Windows halves: `CreateDirectoryW` with SDDL `D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;<daemon SID>)`; `CreateFileW(GENERIC_WRITE, share 0, CREATE_NEW, SDDL D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;<daemon SID>))` → `msvcrt.open_osfhandle(h, O_WRONLY|O_BINARY)`; read = `CreateFileW(GENERIC_READ|READ_CONTROL|WRITE_DAC, FILE_SHARE_READ|FILE_SHARE_DELETE, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT)` → `open_osfhandle(h, O_RDONLY|O_BINARY)`.
- Windows `check` rule (OD2), allowlist A = {daemon token-user SID, `S-1-5-18`, `S-1-5-32-544`}: REFUSE reparse point / wrong kind / file with `nNumberOfLinks != 1`; REFUSE owner ∉ A; REPAIR (via `SetSecurityInfo(... DACL|PROTECTED_DACL ...)` with the canonical DACL + `log.warning("nodevault: repaired access list on %s (removed %s)", path, sids)`) when any ACCESS_ALLOWED ACE names a SID ∉ A, or the root/`keys/` DACL is not protected. All-allowlisted ACEs (explicit or inherited) pass — no repair loop (R-W6). Deny ACEs allowed. Key bytes never logged. Daemon SID from its own process token (`GetTokenInformation(TokenUser)`), never an account name lookup.
- Reuse the `host_platform/windows.py` `_win()` bindings (`GetSecurityInfo` `:1174-1182`, `CreateFileW` with SDDL `:1001`, `ConvertStringSecurityDescriptorToSecurityDescriptorW`); bind `SetSecurityInfo`, `GetFileInformationByHandle`, `CreateDirectoryW` beside them. ctypes only, no pywin32.
- `apps/jaahd/nodevault_store.py`: `_check_owner` and the open/check/narrow bodies of `_ensure_dir`, `_read`, `_write_locked` move behind the seam. Keep `os.link`, `os.replace`, `_fsync_dir`. `secret_files()` skips reparse points as well as symlinks. New `Keystore.repair()` runs `check` over root, `keys/` and every file under `_WRITE_LOCK` and returns findings.
- `apps/jaahd/secret_modes.py`: the Windows early return (`:262-263`) calls `Keystore.repair()` when the keystore dir exists and returns its findings; module docstring keeps "mode bits mean nothing" and adds "except the NodeVault keystore, whose DACL `nodevault_private` checks".
- Remove the four refusals: `nodevault_identity.py:47-48`, `nodevault_commands.py:93-95`, `cred_broker.py:1380-1382`, `cred_broker.py:1487-1489`. `node_capable()` becomes the same rule on every OS (`probe()` and the boundary rule stay fail-closed — R-A1).
- Replace (never delete unreplaced) `test_nodevault_commands.py::test_a_windows_node_refuses_every_keystore_verb` → a Windows node serves the verbs; `test_nodevault_fetch.py::test_a_windows_node_never_promises_a_delivery` → a Windows node opens a sealed field. New tests over a fake DACL source: wide ACE → repaired and logged; foreign owner → refused; reparse point → refused; two links → refused; a read handle held open across `install_key(make_current=True)` and `remove_key` (R-W5); a byte round-trip of PEM/JSON proving `O_BINARY` (R-W1). All must pass `tools/tests/test_posix_only_calls.py`.
- Docs: compliance doc gains the **Windows** row only: `C:\ProgramData\Jaah\state\nodevault\`, a protected DACL (SYSTEM, Administrators, the `jaahd` service SID), owner the service SID; "Who can open what" stays "the runner never"; add rotation as the operator remedy after a repaired widening (R-W3). ADR-0154 entry's Home line points to the seam. `apps/jaahd/README.md` NodeVault paragraph drops any Linux-only implication.
### AC-7 ordering (naive-pass guard, Architecture §4) — mandatory
win-t8703 runs 1.2.4150 (pre-change). 1. Before enqueuing the PR, record win-t8703's Settings → NodeVault row ("Can't hold keys — Update Jaah on this node", screenshot in the report). 2. Turn auto-update OFF for that node in the console. 3. After the dev deploy, run the console's Update action and time the row reaching Delivered (≤ 10 min). 4. Turn auto-update back ON. C3 runs its Windows live re-assert step after this.
Credentials & reach (Architecture §6 rows, verbatim):
- UI checks for AC-2, AC-3, AC-5…AC-10 (Settings → NodeVault, Nodes, Credentials, Audit) and the AC-7 auto-update toggle — `org/console/dev-login`: tenant Owner/Admin on the dev CP UI `https://app.dev.jaah.ai` — **verified**: on roster, `credential_get` succeeded, `https://app.dev.jaah.ai/` answered HTTP 200. Its stored base URL host does not resolve, so use app.dev.jaah.ai
- AC-9 org transfer, AC-10 plan term off, AC-6 cross-org node list (operator plane) — `me/jaah/debug/main/operator-login`: operator on dev — **verified**: signed in to app.dev.jaah.ai in a browser. Nodes (all orgs) listed 13 online nodes
- The Windows node for AC-2/3/5/7/8/9/10 and C3(b) — `win-t8703` on dev: os windows, x64, online, 1.2.4150 (pre-change) — **verified**: present and online in the operator Nodes list. Used instead of a fresh cloud launch (PRD named "a Windows cloud node launched from the console"; an existing one serves and keeps AC-7's pre-change state). A fresh launch is therefore not needed
- Runner-account shell on win-t8703 (`jaah nodevault …`, `type` as runner) — a Jaah console session on that node via the dev UI (runs as `.\jaah`) — **verified** as reachable UI (same credential/probe). The session itself is opened by C2
- **Elevated** step on win-t8703 (AC-5: add a Users/runner grant, then `icacls`) — an Administrator shell on the node — **deferred**: no admin shell reach was verified from this node. The console session runs as the runner. C2 proves the repair rule with unit tests over a fake DACL source. On the node it first tries the console's admin path; if none exists, it ASKS the operator for one round and otherwise records the live widen-and-repair step as deferred with that answer
- The Mac for AC-1, AC-4 and the Mac halves — a macOS node on dev — **deferred**: the operator answered "Defer Mac live checks". No macOS node is on dev (OD3)
- Unit/gate oracles (AC-11, AC-12) — worktree only (`./dc test`, `./dc check full`, `python3 tools/check_context.py`) — **verified**: nothing beyond the worktree
- AC-13 deploy — the PR's Jaah Deploy in GitHub, via `jaah-forge` (pre-authenticated) — **verified**: no extra credential
Out of scope: Do NOT touch any of these (PRD §7, verbatim):
- **N1** No OS secret store: no macOS Keychain, Windows DPAPI, CNG key storage or TPM binding (D1). Deferred follow-up: OS-keystore hardening for macOS/Windows.
- **N2** No Intel (x86_64) macOS bundle and no Windows ARM bundle (D2). They are not supported nodes today.
- **N3** No change to the CP's crypto: the envelope format, `nv:` key ids, RSA-4096/OAEP/AES-GCM constructions, `apps/api/app/credential_crypto.py`, `credential_keys.py`, ADR-0137 KMS tier, and the browser's key generation/backup in `apps/web/src/lib/nodeVaultKeys.ts` stay unchanged. Vectors in `apps/jaahd/tests/fixtures/nodevault_vectors.json` must keep passing as-is.
- **N4** No new console copy or UI that explains why a node cannot hold keys (D4). The existing out-of-date-daemon label stays (A3).
- **N5** No weakening of the passive-breach guarantee or the runner-never-gets-a-key claim to make a node capable (A1).
- **N6** No change to which credentials NodeVault covers (node-consumed, non-OAuth2) or to CP-consumed credentials.
- **N7** No EC2 mac cloud launch (D6). MicroVM guest sessions still see no keystore. The Multi-Node Host process stays excluded.
- **N8** No change to the NodeVault plan feature or its per-org override semantics (A5).
Architecture §7 additionally deferred: OS-keystore hardening (Keychain/DPAPI/TPM); rewriting operator systemd drop-ins (warn only). No CP (`apps/api`) or web (`apps/web`) change in this epic: the "Can't hold keys — Update Jaah" label (`apps/web/src/pages/settings/nodeVault/api.ts:169`) stays as the old-daemon fallback.
Child boundary: do NOT touch `vendor_path.py`, `packaging/platform-pins.toml`, `tools/platform_bundle.py`, the vendor README or the compliance doc's macOS row (C1); do NOT touch `packaging/postinst`, the updaters or `environment.session_user` (C3). POSIX keystore behaviour must stay byte-for-byte identical.
Done means:
- **AC-2 (R2) Windows node holds the key.** Same flow and same observations as AC-1, on the Windows cloud node, run from a console session or an elevated PowerShell. `jaah nodevault …` must no longer return `unavailable`. (AC-1's flow: open Settings → NodeVault on dev and find the node's row. Observe: the status is **Delivered** for the current key, never "Can't hold keys". On the node, run `jaah nodevault fingerprint`: its output equals the fingerprint on the node's detail page (Nodes → the node → identity fingerprint field) and on the NodeVault row. `jaah nodevault keys` lists the current key hash shown in Settings → NodeVault.) — oracle: on win-t8703, screenshots + command output in the report.
- **AC-3 (R1, R2) Tasks open NodeVault credentials — Windows half.** Verify: create a NodeVault-sealed credential (Credentials → new, sealed under NodeVault) with a non-secret known test value. Run one task pinned to the Mac and one pinned to the Windows node; each fetches the credential with `credential_get` and reports whether the value equals the known value (never printing it). Observe: both report a match, with no `nodevault_key_missing` or `nodevault_open_failed`. Repeat once while the node is briefly offline at delivery time: the fetch succeeds after the node comes back, with no manual retry in the console. — oracle: Windows task run result; Mac half (deferred — OD3).
- **AC-5 (R2, D1) Windows keystore is daemon-only (amended by OD2).** Verify on the Windows node: `icacls` on the keystore dir and each file; then, from a Jaah console session (which runs as the runner account), `type` the identity and key files. Observe: the access list grants read only to the daemon's service identity, SYSTEM and Administrators, with inheritance not widening it. The runner account's read fails with "Access is denied". If an admin adds a grant for the runner account or Users, the daemon ~~refuses to use the keystore (fail closed)~~ **repairs the access list back to daemon/SYSTEM/Administrators and logs a warning (OD2)**. A runner-planted link or junction inside the keystore is refused. — oracle: `icacls` + runner `type` output on win-t8703; widen-and-repair step live if an admin path exists, otherwise (deferred — no Administrator shell reach verified; Architecture §6) and proven by the fake-DACL unit tests.
- **AC-7 (R5) An upgraded node becomes capable unaided — Windows.** Verify: on a Mac or Windows node still running a pre-change daemon, Settings → NodeVault shows "Can't hold keys — Update Jaah on this node". Update it through the console's normal update action. Observe: within 10 minutes of the daemon restarting, the row reads **Delivered**, with no "Reset identity" or other operator step. — oracle: before/after screenshots and timing per the AC-7 ordering above.
- **AC-8 (R4) Rotation, retirement and migration reach Mac and Windows — Windows half.** Verify in Settings → NodeVault: rotate the key, then retire the old key; separately, migrate one KMS credential to NodeVault. Observe: both nodes show the new key Delivered. After retirement, `jaah nodevault keys` on each node no longer lists the old hash. The migrated credential opens on both nodes (AC-3's task). Each step is an audit event under Audit → Users, Area = NodeVault. — Mac half (deferred — OD3).
- **AC-9 (R4) Identity reset and org transfer — Windows half.** Verify: run the operator's "Reset identity" on the Windows node, then (operator plane) transfer the Mac node to another org. Observe: the Windows node re-registers a new fingerprint and is re-granted (Delivered). After the transfer, the Mac's `jaah nodevault keys` lists none of the old org's keys and the old org's NodeVault list no longer shows it. — Mac org-transfer half (deferred — OD3).
- **R4 org-transfer erase on Windows** — covered via grant retirement → `retire_key` → `remove_key` (AC-8 retirement + the R-W5 open-handle test); cite that path in the report.
- **AC-10 (A5) Plan-off behaviour is identical — Windows half.** Verify: turn the org's NodeVault plan term off (operator), then run AC-3's tasks again. Observe: the fetch still succeeds on both nodes, while setup, rotation and new NodeVault credentials are refused as on Linux. Restore the plan term afterwards. — Mac half (deferred — OD3).
- **AC-11 (R6, A1) The guarantee is unchanged and documented.** Verify: `./dc test apps/api/tests/test_nodevault_at_rest_proof.py apps/api/tests/test_nodevault_dbos_scan.py` passes. Read `docs/compliance/nodevault-key-handling.md`. Observe: the "Where each key lives" table has a row per OS (Linux, macOS, Windows) naming path, protection and owning principal. The "Who can open what" table still says the runner never gets a key, on every OS. `python3 tools/check_context.py` exits 0.
- **AC-12 Linux behaviour does not regress.** Verify: the jaahd NodeVault suites (`apps/jaahd/tests/test_nodevault_*.py`, `test_vendor_native.py`) and `./dc check full` pass in the merge lane. Observe: green. The Windows/macOS "refuses everything" tests are replaced by tests asserting R1/R2, not deleted unreplaced.
- **AC-13 Deploy.** Observe: the PR(s)' Jaah Deploy to dev succeeds, and the dev node bundles for macOS and Windows carry the change (`jaah --version` on each node shows the deployed version) before AC-1–AC-10 are run. — Windows half; macOS (deferred — OD3).
Outcome
Activity
- Draft
- Ready
- Runner started
- Awaiting response
- Awaiting response
- Runner resumed