# Research: v0.12 Security Hardening (Zero-Trust Identity) ## Source material The v0.12 threat model was produced by a comprehensive security-surface review (Phase 0 RESEARCH, 2026-08-07) covering the entire Orca codebase AND the operating-system-level surface it touches. The review ingested: - v0.11 closeout (CHECKPOINT.json: milestone_complete=true, 24 phases shipped, threat model produced in P15.5). - The 12-area security-surface inventory (see "Threat model findings" below), produced by deep code exploration of every `internal/` package, every `scripts/` file, the emitter surface, the OS-touching CLI commands, and the dual-write window. - The operator's locked decisions (D-238..D-247) on zero-trust identity: bundled Dex + WebAuthn, master key seal-to-OIDC + Shamir, no Orca credentials (R-021). ## Load-bearing rule adopted **R-021**: *Orca never issues, stores, or accepts human-identity credentials. Human identity is exclusively external (OIDC). Machine identity is exclusively mTLS/SPIFFE. No passwords, no Orca-issued tokens, no CA-key passphrases.* ## Threat model findings (F1..F25) | # | Area | Finding | Severity | Phase | REQ | |---|------|---------|----------|-------|-----| | F1 | ACL | `acl.ACL.Check` exists but no caller enforces it -- daemon & SSH-push have zero authz | Critical | P06 | REQ-145 | | F2 | Audit | Audit log is plain SQLite INSERT -- no hash chain, no MAC, not tamper-evident | Critical | P10 | REQ-125 | | F3 | Runtime | `podman.go:57` & `wasm.go:39` interpolate cmdStr unquoted into SSH exec -> command injection | Critical | P01 | REQ-119 | | F4 | Namespace | `ns create` doesn't reject `..`/`/` -> path traversal | Critical | P02 | REQ-120 | | F5 | Txn | `apply.sh` python heredoc writes to arbitrary paths from desired-state.json -- no allowlist | Critical | P03 | REQ-121 | | F6 | Daemon | Plaintext mode (default) has no auth on read endpoints; `--pprof` unauthenticated | High | P09 | REQ-123/124 | | F7 | Backup | `Restore` creates symlinks without validating Linkname -> symlink-to-/etc/shadow | High | P12 | REQ-127 | | F8 | SQLite | DBs unencrypted, no explicit file mode (defaults to umask 0644) | High | P21 | REQ-136 | | F9 | SPIFFE | `VerifySVID` checks URI SAN but not the cert chain against the CA | High | P11 | REQ-126 | | F10 | step-ca | `step ca certificate` writes SVID privkey to /tmp/orca-* world-readable | High | P13 | REQ-128 | | F11 | Scripts | `orca-aggregate.sh:64` interpolates raw peer output into JSON -> JSON injection | High | P16 | REQ-131 | | F12 | Secrets | No master.key rotation; no passphrase/KDF wrapping (raw 32 bytes, 0600-only) | High | P14 | REQ-129 | | F13 | File modes | `EnforceFileModes` only checks ca.{crt,key} -- SSH key, master key, server cert not re-verified | Medium | P15 | REQ-130 | | F14 | install.sh | curl|bash with no checksum/signature verification of the tarball | High | P17 | REQ-132 | | F15 | known_hosts | `Flock` creates 0600 if missing but doesn't tighten pre-existing looser perms | Medium | P24 | REQ-139 | | F16 | Dual-write | Legacy CA/mTLS/daemon marked Deprecated but still load-bearing -- expanded attack surface | Medium | P23 | REQ-138 | | F17 | History | Real GITEA_TOKEN committed in 0cba1aa, still in git history | High (human-gated) | P28 (gate) | -- | | F18 | Drift | `orca-pull.sh` R-020 grep-based JSON parsing fragile; drift events unauthenticated | Medium | P16/P25 | REQ-131/140 | | F19 | Migration | `ALTER TABLE DROP COLUMN` irreversible; `copyFile` non-atomic; no rollback | Medium | P22 | REQ-137 | | F20 | OS scripts | `orca-aggregate.sh`/`orca-remediate.sh` run as root with TOFU SSH (accept-new) | Medium | P16/P24 | REQ-131/139 | | F21 | nftables | Emitted ruleset has SYN-flood + rate-limit but no conntrack bounds, no input default-deny | Medium | P18 | REQ-133 | | F22 | sudoers | `OrcaOperator` sudoers has NOEXEC on pct/qm but allows apt-get/dpkg without NOEXEC | Medium | P19 | REQ-134 | | F23 | system user | Proxmox creates login user (-m -s /bin/bash); peer-setup creates nologin -- inconsistent privilege | Medium | P20 | REQ-135 | | F24 | Dispatch | No request body size limits (json.Decode with no MaxBytesReader) | Low | P09 | REQ-124 | | F25 | Transport | `classifyDialErr` is substring-based; no SSH-exec rate limiting | Low | P24 | REQ-139 | ## Zero-trust identity model (NEW in v0.12) ### Two identity layers, zero overlap - **Human operators** -> OIDC (external IdP, BYO) OR the bundled Dex with a WebAuthn (passkeys) connector as the default password-free authenticator. `orca auth login` / `orca auth register` open the default browser to the Dex WebAuthn endpoint via OIDC authorization-code + PKCE + local loopback redirect. After the WebAuthn ceremony (biometric/security key), Dex redirects back with an auth code; CLI exchanges for a short-lived ID token (1h) + refresh. Headless/CI fallback: device-code flow. - **Machine-to-machine** -> mTLS + SPIFFE SVIDs (unchanged from v0.11). ### Why WebAuthn satisfies "no passwords anywhere" Passkeys are **public-key credentials**. The private key is generated on the authenticator (TPM/security key/phone Secure Enclave) and never leaves it. The server (Dex) stores only the **public key** + credential ID + sign count. There is no password, no shared secret, no replayable credential. This is the strongest authentication primitive available and directly satisfies R-021. ### Bundled Dex architecture - **Dex** (github.com/dexidp/dex) is the OIDC frontend. Orca bundles a Dex binary + config template, deployed via `orca auth init-idp` as a systemd unit on the lead, fronted by Traefik (R-017, step-ca cert). - **`orca-webauthn-connector`** is a custom Dex connector (~300 LoC Go, using `github.com/go-webauthn/webauthn`). It serves: - `GET /orca/webauthn/register` -- registration HTML/JS page. - `POST /orca/webauthn/register/begin` -- WebAuthn registration challenge (random nonce, user info). - `POST /orca/webauthn/register/finish` -- attestation verification, credential storage. - `GET /orca/webauthn/login` -- login HTML/JS page. - `POST /orca/webauthn/login/begin` -- assertion challenge. - `POST /orca/webauthn/login/finish` -- assertion verification, OIDC `sub` extraction, redirect with auth code. - **Passkey storage**: SQLite at `ClusterDir()/webauthn-credentials.db` (0600). Schema: `credentials(user_id TEXT PRIMARY KEY, credential_id BLOB, public_key BLOB, sign_count INTEGER, aaguid TEXT, created_at TEXT)`. Public keys only; no private keys, no secrets. - **BYO external IdP override**: `oidc.issuer` in config repoints to an external IdP. The bundled Dex + WebAuthn connector is bypassed; the external IdP's authenticators (including its own WebAuthn) are used. Orca never sees the upstream credentials. ### RQ-1 resolution (RESEARCH binding question) **RQ-1**: How does the bundled Dex bootstrap an upstream identity without any password, given the mTLS-only constraint? **Answer (resolved by C3/D-240)**: The bundled Dex's upstream authenticator IS the WebAuthn connector. No external password source is needed for the bundled path. The WebAuthn connector serves the registration + login ceremonies directly; Dex maps the credential ID to an OIDC `sub`. BYO-IdP covers password-based upstreams (LDAP/AD) if an operator insists -- but those never flow through Orca. **C-37 fallback** (kept if WebAuthn proves infeasible): bundled Dex ships mTLS-client-cert-only (Traefik `X-Forwarded-Client-Cert` header -> Dex `typed-external-connector`). Password-based upstreams require BYO external IdP. The "no Orca credentials" invariant holds regardless. ### Master key sealing architecture - **Seal**: at `orca cluster seal`, the in-memory master key is encrypted with a key derived from the operator's OIDC ID token (HKDF-SHA256 of the token's `sub` + a fresh 32-byte salt). The sealed blob (`salt || ciphertext`) is stored at `ClusterDir()/master.key.sealed` (0600). The raw key is zeroed from memory. Shamir 3-of-5 shards are printed for offline recovery. - **Unseal**: at `orca cluster unseal`, the operator authenticates via OIDC (WebAuthn ceremony). The resulting ID token's `sub` + the stored salt derive the unwrapping key. The master key is unwrapped into memory and held for the cluster's lifetime. Zeroed on shutdown. - **Recovery**: if the IdP is lost, the operator presents 3 of 5 Shamir shards to `orca cluster unseal --recovery`. The shards reconstruct the seal key; the master key is unwrapped. No backdoor. - **mTLS-only offline path**: for the single-operator fully-offline case (no OIDC), the seal key is derived from the cluster's own CA. The operator holds the CA (a cert, not a password). Shamir recovery applies to the OIDC-sealed mode only. ### Offline-first reconciliation (R-003) The OIDC provider must be reachable to unseal the master key and to authenticate operators. For offline/air-gapped clusters, the operator runs the **bundled Dex on the lead** (offline). For the single-operator fully-offline case, the operator can skip OIDC and rely on mTLS-only machine identity (no human authn needed -- the operator holds the pre-staged SSH key + mTLS cert; no password, no token). Orca stays minimal (no bundled IdP beyond Dex); it validates tokens against whatever issuer the operator configures. ## Dependency posture (new in v0.12) v0.12 adds these dependencies (all CGO-free, audited): - `github.com/coreos/go-oidc/v3` -- OIDC client (token verification, JWKS, ID token parsing). Pure Go. - `github.com/go-webauthn/webauthn` -- WebAuthn library (registration, login, attestation/assertion verification). Pure Go. - `github.com/dexidp/dex` -- bundled Dex binary (vendored, not a Go import; deployed as a separate systemd unit). Apache-2.0. - `golang.org/x/crypto/ssh/...` -- already a dependency (sshpush). No CGO. No gRPC. No ConnectRPC. No YAML parser. The "stdlib + minimal deps" posture (D-008) is preserved. ## Codebase reality (verified 2026-08-07) - `internal/acl/acl.go` -- ACL exists but is unenforced (F1). P06 rewrites it (remove KindToken, add KindOidc, wire enforcement). - `internal/runtime/podman.go:57`, `internal/runtime/wasm.go:39` -- unquoted cmdStr interpolation (F3). P01 fixes via shellQuote. - `internal/cli/ns.go:nsCreateCmd` -- no `..`/`/` rejection (F4). P02 adds `validateNamespaceName`. - `internal/txn/txn.go:renderApplyScript` -- arbitrary path writes (F5). P03 adds prefix allowlist. - `internal/security/ca.go` -- legacy CA, deprecated but load-bearing (F16). P23 deletes it (gated on P06/P08/P09/P11). - `internal/secrets/secrets.go` -- master key raw file, no rotation (F12). P08 seals it to OIDC; P14 adds rotation. - `internal/audit/audit.go` -- plain SQLite INSERT (F2). P10 adds hash-chain + HMAC. - `internal/emitter/nft.go` -- no conntrack/default-deny (F21). P18 hardens the ruleset. - `internal/proxmox/bootstrap.go:29` -- `--password` bootstrap (F23, R-021 violation). P07 removes it. - `internal/identity/spiffe.go:95` -- no chain validation (F9). P11 fixes. - `scripts/install.sh` -- no checksum verification (F14). P17 adds SHA256SUMS + GPG signature. - `scripts/orca-aggregate.sh:64` -- raw JSON interpolation (F11). P16 replaces with jq/Go. ## Alignment with existing gates - **C-19** (threat model) -- v0.11 P15.5 produced the initial threat model; v0.12 is the comprehensive expansion (full OS surface). - **C-08** (SPIFFE spike) -- passed; v0.12 P11 hardens the verification path. - **R-001..R-020** -- unchanged; R-021 is an extension, not a reversal. - **D-008** (no CGO) -- preserved; all new deps are pure Go. ## Risks (for GRILL to pressure-test) - **P07 (password removal) is breaking** -- mitigation: C-34 migration gate (`--accept-identity-migration`). - **P08 (master key seal) is the riskiest phase** -- a bug corrupts all secrets at rest. Mitigation: `--dry-run`, atomic re-encryption, automatic rollback to old sealed key on any failure. - **P21 (SQLite encryption) may need CGO** -- C-31 fallback to file-mode 0600 + documented threat if SQLCipher needs CGO. No CGO. - **P23 (dual-write closure) is high-impact** -- removing the legacy CA breaks `orca init`/`orca cert` if step-ca isn't fully wired. Mitigation: gate on P06/P08/P09/P11, full test coverage before deletion. - **P05 (WebAuthn connector) is new ground** -- ~300 LoC custom Dex connector. Mitigation: C-37 fallback (mTLS-client-cert-only) if WebAuthn proves infeasible; virtual-authenticator integration tests (P26) using `go-webauthn` test helpers. - **Bundled Dex is a new systemd unit + Traefik route** -- operational surface growth. Mitigation: `orca doctor oidc` checks Dex health, JWKS reachability, WebAuthn endpoint TLS. - **C-32 human gate** (leaked GITEA_TOKEN) could stall the final ship. Escalation path: ship as `v0.11.29-rc1` if rotation pending, `v0.11.29` when confirmed. ## Next steps Phase 0 proceeds to IDEATE (produce the 30 net-new requirements REQ-119..REQ-148), then PLAN (29 phases, wave ordering, persona assignments), then GRILL (ratify C-29..C-38).