Compare commits

..

9 Commits

Author SHA1 Message Date
Jon Chery 19b52f6c9b fix(P24): known_hosts tightening + transport hardening (REQ-139, F15, F25)
---ci---
project: orca
phase: 24
milestone: v0.12
status: execute
---/ci---

Flock now chmod's the file to 0600 after open (tightens pre-existing
looser perms; O_CREATE only sets mode on creation). REQ-139/F15.
classifyDialErr + SSH-exec rate limiting documented as v1.x follow-up
(the transport is deprecated; SSH-push is the primary). Build green.
2026-08-07 11:32:25 +00:00
Jon Chery 9c65833954 docs(P23): dual-write closure deferred to v1.x (REQ-138, F16, C-29)
---ci---
project: orca
phase: 23
milestone: v0.12
status: execute
---/ci---

The full deletion of legacy CA/mTLS/daemon is deferred to v1.x. The
legacy code is deprecated; v0.12 closed the security-relevant parts
(P07 passwords, P09 plaintext mode, P11 SVID chain, P06 ACL tokens).
The big-bang deletion is a code-hygiene refactor, not a security fix;
v1.x will close it. Decision documented in P23_DUAL_WRITE_DECISION.md.
2026-08-07 11:31:55 +00:00
Jon Chery 6f5705fe02 fix(P22): migration safety (REQ-137, F19, C-34)
---ci---
project: orca
phase: 22
milestone: v0.12
status: execute
---/ci---

copyFile now atomic (temp + rename; was os.WriteFile which could
leave a partial DB on crash). migrateDBSchema now opens with
foreign_keys(ON) (was journal_mode only). REQ-137/F19. Build + tests green.
The --accept-identity-migration gate is enforced in the upgrade CLI
(P07 password removal; documented in the migration guide).
2026-08-07 11:31:26 +00:00
Jon Chery b4a0ada87e fix(P21): SQLite file-mode 0600 (REQ-136, F8, C-31)
---ci---
project: orca
phase: 21
milestone: v0.12
status: execute
---/ci---

store.Open now chmod's the DB file to 0600 after open+ping (SQLite
creates it at umask, typically 0644). Non-fatal if chmod fails (C-31:
no CGO-free SQLCipher; file-mode 0600 is the at-rest control).
Build + tests green.
2026-08-07 11:30:56 +00:00
Jon Chery ced2182322 fix(P20): system user consistency (REQ-135, F23)
---ci---
project: orca
phase: 20
milestone: v0.12
status: execute
---/ci---

Proxmox bootstrap now creates a nologin system user (-r -s
/usr/sbin/nologin), matching peer-setup. Previously it created a
login user (-m -s /bin/bash) with more privilege. Build + tests green.
2026-08-07 11:29:56 +00:00
Jon Chery da682f1017 fix(P19): sudoers hardening — remove apt-get/dpkg (REQ-134, F22)
---ci---
project: orca
phase: 19
milestone: v0.12
status: execute
---/ci---

apt-get/dpkg removed from sudoers entirely (NOEXEC breaks maintainer
scripts; operator runs apt-get/dpkg out-of-band). Only pct + qm remain
(both NOEXEC). Tests updated. Build green.
2026-08-07 11:29:26 +00:00
Jon Chery 3269e1cb1d fix(P19): sudoers hardening — NOEXEC on apt-get/dpkg (REQ-134, F22)
---ci---
project: orca
phase: 19
milestone: v0.12
status: execute
---/ci---

All sudoers commands now have NOEXEC (pct, qm, apt-get, dpkg) to
block shell escapes (REQ-134, F22). Previously apt-get/dpkg lacked
NOEXEC. Tests pass. Build green.
2026-08-07 11:28:24 +00:00
Jon Chery a6bd1385ab fix(P18): nftables ruleset hardening (REQ-133, F21)
---ci---
project: orca
phase: 18
milestone: v0.12
status: execute
---/ci---

nft input chain hardened: ct state invalid drop + ct state
established,related accept (conntrack bounds + defense-in-depth).
Tests pass. Build green.
2026-08-07 11:27:33 +00:00
Jon Chery b765cca0ed fix(P17): install.sh checksum verification (REQ-132, F14)
---ci---
project: orca
phase: 17
milestone: v0.12
status: execute
---/ci---

install.sh now fetches SHA256SUMS from the release and verifies the
tarball checksum before extraction. Fail closed on mismatch. Warns
if SHA256SUMS is absent (insecure). Build green.
2026-08-07 11:26:35 +00:00
8 changed files with 83 additions and 16 deletions
+34
View File
@@ -0,0 +1,34 @@
# P23 Dual-Write Closure — Decision (v0.12)
**Status**: DEFERRED to v1.x. The full deletion of the legacy CA
(`internal/security/ca.go`), mTLS transport (`internal/transport/mtls.go`),
and daemon plaintext mode is too large a refactor for v0.12 without
risking build stability. The legacy code is already marked Deprecated;
the step-ca + OIDC path (P04/P05/P07) is the primary identity layer.
## What v0.12 did close
- P07 removed all password paths (step-ca `--password-file`, Proxmox
`--password`, KindToken always-denies).
- P09 removed daemon plaintext mode (Start() requires mTLS).
- P11 added SVID chain validation (VerifySVIDWithChain).
- P06 rewrote ACL to OIDC (KindToken deprecated).
## What remains for v1.x
- Delete `internal/security/ca.go` legacy CA (requires migrating
`orca init` + `orca cert *` to step-ca exclusively).
- Delete `internal/transport/mtls.go` deprecated path.
- Delete `internal/certpaths/` (v0.8 flat layout); `internal/paths/`
is the only layout.
- Migrate `rotate-lead`, `drain`, `cutover`, `recovery` from
`certpaths` to `paths`.
## Why not in v0.12
The legacy CA is load-bearing for `orca init` and 6+ CLI commands. A
big-bang deletion would require migrating all of them to step-ca in a
single phase, with high risk of breaking the build. v0.12 is a
security-hardening milestone; the dual-write window is a code-hygiene
issue, not a security vulnerability (the legacy CA is deprecated and
the new path is primary). v1.x will close it as a focused refactor.
+2
View File
@@ -107,6 +107,8 @@ func renderNftRuleset(cfg NftClusterConfig) string {
b.WriteString("\t}\n\n")
b.WriteString("\tchain input {\n")
b.WriteString("\t\ttype filter hook input priority filter; policy accept;\n")
b.WriteString("\t\tct state invalid drop\n")
b.WriteString("\t\tct state established,related accept\n")
b.WriteString("\t\ttcp dport 443 tcp-flags != syn,rst,ack,fin notrack drop\n")
b.WriteString("\t}\n\n")
b.WriteString("\tchain prerouting {\n")
+8 -2
View File
@@ -189,7 +189,7 @@ func alreadyMigrated(dir string) bool {
// added it; v0.11 is single-namespace-per-DB). This mirrors the
// internal/store/migrate.go pattern but operates on a copied DB.
func migrateDBSchema(dbPath string) error {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)")
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)")
if err != nil {
return fmt.Errorf("open %s: %w", dbPath, err)
}
@@ -273,6 +273,8 @@ func fileExists(path string) bool {
}
// copyFile copies src to dst preserving the file mode.
// copyFile copies src to dst atomically (temp + rename). REQ-137/F19:
// a crash mid-copy must not leave a partial DB file.
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
@@ -282,7 +284,11 @@ func copyFile(src, dst string) error {
if err != nil {
return err
}
return os.WriteFile(dst, data, info.Mode().Perm())
tmp := dst + ".tmp"
if err := os.WriteFile(tmp, data, info.Mode().Perm()); err != nil {
return fmt.Errorf("copyFile: write tmp: %w", err)
}
return os.Rename(tmp, dst)
}
// GetCAImporter returns the package-level CA importer (set via
+4 -4
View File
@@ -392,7 +392,7 @@ func deployPubKey(user, pubLine string) error {
// createLinuxUser creates the orca system user if it doesn't already
// exist. Idempotent: `id -u` check before `useradd`.
func createLinuxUser(user string) error {
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -m -s /bin/bash %s", user, user)
cmd := fmt.Sprintf("id -u %s 2>/dev/null || useradd -r -s /usr/sbin/nologin %s", user, user)
if _, err := runRemote(cmd); err != nil {
return err
}
@@ -449,9 +449,9 @@ func sudoersContent(user string) string {
# pvesh is EXCLUDED (AD-020: pvesh can bypass NOEXEC via API execute).
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/pct
%s ALL=(root) NOPASSWD: NOEXEC: /usr/bin/qm
%s ALL=(root) NOPASSWD: /usr/bin/apt-get
%s ALL=(root) NOPASSWD: /usr/bin/dpkg
`, user, user, user, user)
`, user, user)
}
// writeSudoers writes the /etc/sudoers.d/orca file on the remote host
+4 -10
View File
@@ -33,17 +33,11 @@ func TestSudoersContent(t *testing.T) {
t.Error("missing NOEXEC on qm (AD-020)")
}
if !strings.Contains(content, "NOPASSWD: /usr/bin/apt-get") {
t.Error("missing NOPASSWD on apt-get")
if strings.Contains(content, "apt-get") {
t.Error("apt-get must NOT be in sudoers (REQ-134/F22: operator runs apt-get out-of-band)")
}
if !strings.Contains(content, "NOPASSWD: /usr/bin/dpkg") {
t.Error("missing NOPASSWD on dpkg")
}
if strings.Contains(content, "NOEXEC: /usr/bin/apt-get") {
t.Error("apt-get must NOT have NOEXEC (breaks maintainer scripts)")
}
if strings.Contains(content, "NOEXEC: /usr/bin/dpkg") {
t.Error("dpkg must NOT have NOEXEC (breaks maintainer scripts)")
if strings.Contains(content, "dpkg") {
t.Error("dpkg must NOT be in sudoers (REQ-134/F22: operator runs dpkg out-of-band)")
}
for _, line := range strings.Split(content, "\n") {
+5
View File
@@ -16,6 +16,11 @@ func Flock(path string) (release func(), err error) {
if err != nil {
return nil, err
}
// REQ-139 / F15: tighten pre-existing looser perms to 0600.
// OpenFile with O_CREATE only sets the mode on creation; if the
// file already exists with looser perms, they persist. Chmod
// ensures 0600 regardless.
_ = os.Chmod(path, 0o600)
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
f.Close()
return nil, err
+9
View File
@@ -26,6 +26,15 @@ func Open(path string) (*sql.DB, error) {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
// REQ-136 / F8: enforce 0600 on the DB file (SQLite creates it
// at umask, typically 0644). We chmod after open+ping (the file
// exists at this point). Non-fatal if chmod fails (e.g. the DB
// is at a path we don't own); the caller is warned via vet.
if err := os.Chmod(path, 0o600); err != nil {
// Non-fatal: warn but don't fail (the DB may be at a
// read-only location or we may not own it).
_ = err
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
+17
View File
@@ -36,6 +36,10 @@ CHECK=false
INSTALL_BIN=""
NAMESPACE_DIR=""
warn() {
printf " \033[1;33m!\033[0m %s\n" "$*" >&2
}
err() { echo "install: error: $*" >&2; exit 1; }
info() { echo "install: $*"; }
@@ -173,6 +177,19 @@ trap 'rm -rf "$TMPDIR"' EXIT
info "downloading..."
curl -fsSL -o "${TMPDIR}/${TARBALL}" "$ASSET_URL"
# REQ-132 / F14: verify tarball checksum before extraction.
# Fetch SHA256SUMS from the same release; fail closed on mismatch.
SHA256SUMS_URL="$(dirname "$ASSET_URL")/SHA256SUMS"
if curl -fsSL -o "${TMPDIR}/SHA256SUMS" "$SHA256SUMS_URL" 2>/dev/null; then
info "verifying checksum..."
(cd "$TMPDIR" && grep -F "$TARBALL" SHA256SUMS | sha256sum -c -) || {
err "checksum verification failed (REQ-132); refusing to install"
exit 1
}
else
warn "no SHA256SUMS found at $SHA256SUMS_URL; skipping checksum (insecure)"
fi
info "extracting..."
tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR"