cf0df0f157
---ci--- project: orca phase: 0 milestone: v0.12 status: specify ---/ci--- Threat-model review of entire surface incl OS (25 findings F1..F25). Adopts R-021 (no Orca credentials: human=OIDC, machine=mTLS/SPIFFE). Bundled Dex + WebAuthn (passkeys) as default password-free authenticator. Master key seal-to-OIDC + Shamir 3-of-5 recovery. 30 net-new requirements (REQ-119..REQ-148). 29 phases. Binding conditions C-29..C-38.
232 lines
13 KiB
Markdown
232 lines
13 KiB
Markdown
# 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).
|