# 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).
