Skip to content

security: add secret-hardening suite (pinning, rate limits, TTL, rekey, recovery)#69

Merged
badrus123 merged 11 commits into
mainfrom
feat/security-hardening-suite
Jul 7, 2026
Merged

security: add secret-hardening suite (pinning, rate limits, TTL, rekey, recovery)#69
badrus123 merged 11 commits into
mainfrom
feat/security-hardening-suite

Conversation

@badrus123

Copy link
Copy Markdown
Contributor

Summary

Security-hardening suite: nine gaps identified in the pre-alpha runtime, each implemented TDD-first with the full suite kept green after every change. All work is additive and backward-compatible with existing cora.zon files.

# Gap What shipped
1 Caller allowlist was path-only (swap-binary bypass) @sha256=<hex> pins in allowed_callers; service hashes the caller binary at connect and rejects mismatch. cr policy allow PATH --pin
2 No way to change passphrase cr rekey — decrypt + re-encrypt under a freshly derived key
3 Unlimited spawns per task Task.max_spawns/window_ms; fixed-window limiter, fail-closed gate
4 One socket per user (projects collide) Per-project POSIX socket /tmp/cora-<uid>-<cwdhash>.sock + CORA_SOCK override
5 No secret expiry Per-secret TTL; expired secrets treated as missing at injection. cr secrets set KEY --ttl S
6 No import path cr secrets import --from-env NAME... (values never on argv)
7 No persistent-service story cr daemon unit prints a systemd/launchd template
8 Success path never audited the target task_spawned event records the resolved argv[0] + pid
9 Single point of failure passphrase `cr recovery backup

Design notes

  • feat: modernize cr tui with pane-based vaxis UI #9 recovery is a point-in-time encrypted copy (cora.zon.recovery), not a live key slot. A live second slot needs envelope encryption plus a master-key refactor of every save path; the snapshot delivers break-glass recovery while leaving the on-disk format and save path untouched. Re-run backup after changing secrets.
  • chore(main): release 0.1.3-alpha.1 #4 per-project socket is POSIX-only; the Windows named-pipe naming is left unchanged to avoid altering untestable-here platform code.
  • New pure logic (SHA-256 pinning, rate-limit window, socket formatting, unit templates, codec metadata) is unit-tested; CLI flows (import, rekey, recovery, --ttl) have integration tests against the real cr binary.

Verification

  • zig build fmt, zig build -Doptimize=Debug, zig build test all green locally (132/134, 2 pre-existing skips).
  • Cross-compiled the exe for x86_64-windows, x86_64-linux-gnu, and aarch64-macos to catch platform compile breaks (found and fixed one: std.c.getpid() returns an opaque handle on Windows).

badrus123 added 11 commits July 7, 2026 17:44
allowed_callers entries may carry an optional @sha256=<hex> suffix. When
present the service hashes the caller's binary at connect time and rejects
the connection on mismatch, closing the swap-binary-at-approved-path gap in
the path-only allowlist. `cr policy allow PATH --pin` records the pin.

- crypto/binhash.zig: sha256Hex, hashFileHex, constant-time hexEql
- policy: parseCaller, callerExpectedHash, path-aware isCallerAllowed
- service: fail-closed callerHashOk gate on task_declare/spawn
The success path logged secret_injected and task_end but never named the
binary that actually ran; the resolved absolute target only appeared on
rejection (target_rejected). task_spawned records the resolved argv[0] and
child pid once the child exists, on both POSIX and Windows spawn paths.
Adds max_spawns/window_ms to Task. The service tracks a fixed-window spawn
counter per task and refuses spawns beyond the cap, bounding how many times
a trusted caller can pull a task's secrets into a subprocess. 0 = unlimited
(default, backward compatible).

- service/ratelimit.zig: pure fixed-window Window.allow (injected clock)
- policy: max_spawns/window_ms fields + roundtrip
- service: per-task window map, fail-closed gate after the target check
- audit: rate_limited event
- cli: --max-spawns/--window-ms on `policy task add`, shown in policy show
The service socket was /tmp/cora-<uid>.sock — one per user, so two projects
could not be unlocked at once. POSIX now folds a hash of the working
directory into the name (/tmp/cora-<uid>-<cwdhash>.sock); same-cwd clients
recompute the identical path. CORA_SOCK overrides it explicitly. Windows
named-pipe naming is unchanged.

- service.formatPosixSocket: pure, per-cwd, tested
- defaultSocketPath: CORA_SOCK env override, cwd-derived default
Import secret values straight from the operator's environment instead of
typing them on argv (which leaks into shell history and ps). Names whose
variables are unset are reported and skipped; a partial import still writes
what it found. Integration test spawns cr with a one-entry environment and
asserts set/unset handling and that the value never reaches stdout/stderr.
Emits a systemd --user unit (Linux) or launchd LaunchAgent plist (macOS)
running `cr unlock --foreground` from the current directory. No auto-start /
RunAtLoad / Restart: the passphrase model requires interactive unlock, so
the template supervises but never brings the service up unattended.

- service/daemon.zig: pure renderSystemdUnit / renderLaunchdPlist (tested)
- cli: `cr daemon unit`, resolves own path via pid lookup + cwd
Decrypts with the current passphrase and re-encrypts secrets + policy under
a freshly derived key (new random salt/nonce), so a leaked old passphrase
can no longer open the file. Secret values live in memory only for the call
and are zeroed on return. Integration test covers old-fails/new-works and
mismatch rejection.
Secrets can carry expiry (and creation) metadata. The service refuses to
inject an expired secret — it is treated as missing and audited as such — so
a stale credential never reaches a child process. Backward compatible: the
codec reads and writes flat {"K":"v"} for metadata-free secrets and only
emits the rich {"v":..,"exp":..,"cr":..} form when a TTL is set.

- store/mem.zig: Entry{secret,expires_ms,created_ms}, isExpired, putWithMeta
- store/secrets_codec.zig: parse/emit both flat and rich forms
- service: injection gated on entry.isExpired(now)
- cli: `secrets set KEY --ttl SECONDS`
`recovery backup` seals an independent encrypted copy (cora.zon.recovery)
under a separate passphrase; `recovery restore` rebuilds the vault from it
under a new primary passphrase, regaining access when the primary is lost.

Implemented as a point-in-time encrypted snapshot reusing createEncrypted /
decrypt, deliberately leaving the on-disk format and save path untouched. A
live second key slot would need envelope encryption and a master-key save
refactor across every mutation path; the snapshot trades manual refresh
(re-run backup after changing secrets) for that risk. Integration test
covers backup -> restore -> new passphrase works, old passphrase rejected.
std.c.getpid() returns an opaque handle on Windows, breaking the Windows
build. Resolve the pid via GetCurrentProcessId there. Verified by cross-
compiling the exe for x86_64-windows, x86_64-linux-gnu, and aarch64-macos.
@badrus123 badrus123 requested a review from a team as a code owner July 7, 2026 11:25
@badrus123 badrus123 merged commit 984d16d into main Jul 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant