Ship NodeVault crypto in Mac/Windows bundles
## 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: C1 — NodeVault crypto in the macOS and Windows bundles
Requirements served (PRD §5, verbatim):
- **R1 — macOS arm64 nodes are NodeVault nodes.** On a macOS node: the daemon registers a real identity key with `capable = true`, receives the org's keys, opens NodeVault credentials for its tasks, and serves `jaah nodevault fingerprint` / `jaah nodevault keys`. The keystore is owner-only per D1.
- (crypto half of) **R2 — Windows x64 nodes are NodeVault nodes.** — Windows capability itself lands in C2; this child only puts working crypto in the Windows bundle.
- (macOS half of) **R6 — Docs describe the present.** `docs/compliance/nodevault-key-handling.md` names each OS's keystore location, protection and owning principal. ... The context gate passes (`python3 tools/check_context.py`).
Why (Architecture §2.1): macOS already has the uid split and a working POSIX keystore path; only `nodevault_crypto.probe()` fails because the vendored `cryptography` is manylinux-only and the macOS/Windows bundles ship no darwin/win wheel.
### Approach (Architecture §2.2 — follow it exactly)
- `apps/jaahd/vendor_path.py`: add `native_key() -> str` — `"darwin-arm64"` when `host_platform.is_macos()` and `platform.machine() == "arm64"`, `"windows-x64"` when `host_platform.is_windows()` and `platform.machine() == "AMD64"`, otherwise `platform.machine()` (today's Linux key, so the committed tree does not move; unknown OS/arch → no dir → `ensure_native_on_path()` False, fail-closed). `native_dirs()` and `_RustBinding.find_spec` use it. `RUST_PATH` becomes per-OS, pinned: `cryptography/hazmat/bindings/_rust.abi3.so` (Linux, macOS), `cryptography/hazmat/bindings/_rust.pyd` (Windows). The cffi backend sits under `native/<key>/cp314/` at its wheel's RECORD path (`_cffi_backend.cpython-314-darwin.so`, `_cffi_backend.cp314-win_amd64.pyd`). `ensure_native_on_path()` stays all-or-nothing and fail-closed.
- `packaging/platform-pins.toml`: four `[[artifact]]` rows — `cryptography-50.0.2` (darwin wheel the arm64 CPython 3.14 accepts, universal2 or arm64; and `win_amd64` abi3) and `cffi-2.1.1` cp314 (`macosx_*_arm64`, `win_amd64`) — each with sha256 from PyPI's published digest, `licence` (`Apache-2.0 OR BSD-3-Clause`; `MIT-0`) and `licence_source`. `extract` rules take ONLY the compiled extension + its `dist-info` into `apps/jaahd/vendor/native/<key>/…` and `…/<key>/cp314/`. The pure-Python half is never re-extracted: `native/shared/` stays the one copy and its trim list stays valid. Do NOT commit Mach-O/PE binaries into the repo.
- Single-homed version: a bundle-build check in `tools/platform_bundle.py` refuses a `cryptography` pin whose version differs from `apps/api/requirements.txt`'s (same rule as `apps/jaahd/tests/test_vendor_native.py:106-111`).
- `tools/platform_bundle.py`: the static import-smoke admits `nodevault_crypto`'s lazy imports and resolves them in the staged native tree. A staged-tree test asserts `native/<key>/` holds exactly the binary plus dist-info and each RECORD line for those files matches the staged bytes; it also asserts each pinned `RUST_PATH` appears in its wheel's RECORD (a renamed upstream binary fails the build, not the node).
- macOS code signing (risk R-M1): (1) a staged-tree test asserts the deploy's signing set covers every file under `apps/jaahd/vendor/native/darwin-arm64/`; (2) wherever the deploy signs, it runs `codesign --verify --strict` on those files and fails the deploy on a miss.
- Docs: `apps/jaahd/vendor/README.md` native-tree section gains the two bundle keys and says plainly their bytes are NOT in this directory (pinned in `packaging/platform-pins.toml`, placed only inside the macOS/Windows bundles). `docs/compliance/nodevault-key-handling.md` "Where each key lives" gains the **macOS** row only: `/Library/Jaah/state/nodevault/`, `0700`/`0600`, owner `_jaahd`. (C2 adds the Windows row; do not touch it.)
- Tests: `apps/jaahd/tests/test_vendor_native.py` stays Linux-ELF only. New unit tests: `native_key()` per platform (monkeypatched `host_platform`), pin/version agreement. New code must pass `tools/tests/test_posix_only_calls.py`.
Note: C1's merge alone must NOT make a Windows node capable (the refusals are removed only in C2) — this preserves C2's AC-7 baseline on win-t8703. Do not touch the Windows refusals.
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
- 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)
- 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
- 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 `nodevault_store.py`, `nodevault_identity.py`, `nodevault_commands.py`, `cred_broker.py`, `secret_modes.py`, `host_platform/windows.py` or the compliance doc's Windows row (C2); do NOT touch `packaging/postinst` or the updaters' `JAAH_SESSION_USER` logic (C3).
Done means:
- **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. — oracle: cite the merge-lane gate result plus the new `native_key()`/pin-agreement tests passing.
- **C1 bundle oracle (Architecture §4).** The staged `windows-x64` and `darwin-arm64` trees hold `native/<key>/` with RECORD-matching bytes. — oracle: run the bundle staging for both platforms in the worktree and cite the staged-tree test output; plus the signing-set test for `darwin-arm64` green. Tightened by the Planner: the import-smoke must actually resolve `cryptography.hazmat.bindings._rust` and `_cffi_backend` against the staged `windows-x64` and `darwin-arm64` paths (presence of files alone FAILS this criterion), and a version-mismatched pin must be shown refused (negative test).
- **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. — oracle: the Jaah Deploy run green in GitHub; `jaah --version` on win-t8703 (console session) shows the deployed version. macOS half (deferred — no macOS node on dev, OD3).
- **C1 Windows runtime oracle (Planner).** After C1 deploys to dev, from a Jaah console session on win-t8703 run the bundled python: `vendor_path.ensure_native_on_path()` then `nodevault_crypto.probe()` — must print `True` (a real import of `_rust.pyd` and `_cffi_backend` on Windows). `nodevault_identity.node_capable()` must still print `False` there (C2's AC-7 baseline intact). If the runner account cannot run the bundled python, record this oracle as deferred with the reason (it then lands on C2's AC-2) — never mark it passed on a builder-side or file-presence check. Cite the output.
- **AC-11 (macOS row only).** `docs/compliance/nodevault-key-handling.md` "Where each key lives" has the macOS row naming path, protection and owning principal; `python3 tools/check_context.py` exits 0. — oracle: cite the doc line and the checker exit code.
- **Mac live oracles AC-1, AC-4 and the Mac halves of AC-3, AC-7, AC-8, AC-9, AC-10** (deferred — no macOS node is enrolled on dev; operator chose "Defer Mac live checks", OD3).
Outcome
NodeVault crypto pinned and checked in the macOS/Windows bundles; merged #6849 (ca4fc268b), full gate and Jaah Deploy green. Deferred: win-t8703 runtime oracle (dev-login refused for operator password sign-in, moves to C2 AC-2), Mac live checks (OD3), Authenticode for Windows .pyd. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)