Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cb5d8d8c4 | |||
| 19b52f6c9b | |||
| 9c65833954 | |||
| 6f5705fe02 | |||
| b4a0ada87e | |||
| ced2182322 | |||
| da682f1017 | |||
| 3269e1cb1d | |||
| a6bd1385ab | |||
| b765cca0ed |
@@ -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.
|
||||
@@ -20,7 +20,9 @@ package drift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -31,6 +33,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
type Status string
|
||||
@@ -571,3 +575,28 @@ func MarshalEvent(e Event) ([]byte, error) {
|
||||
}
|
||||
|
||||
var _ Detector = (*DefaultDetector)(nil)
|
||||
|
||||
// VerifyEventSignature verifies the HMAC-SHA256 signature of a drift
|
||||
// event using the per-peer key derived from the master key (REQ-140,
|
||||
// F18). The per-peer key = HKDF-SHA256(masterKey, salt=peerID,
|
||||
// info="orca-drift-event-hmac"). The event payload is the JSON-encoded
|
||||
// event (without the signature field). The signature is base64-encoded.
|
||||
//
|
||||
// This function is called by the aggregator when it receives events
|
||||
// from peers. Unsigned or forged events are rejected. The per-peer key
|
||||
// is deployed to peers at /etc/orca/keys/drift-hmac.key (0600, owned by
|
||||
// the orca user) during peer setup.
|
||||
func VerifyEventSignature(eventJSON []byte, signature string, masterKey []byte, peerID string) bool {
|
||||
if len(masterKey) == 0 || peerID == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
// Derive the per-peer key.
|
||||
hk := hkdf.New(sha256.New, masterKey, []byte(peerID), []byte("orca-drift-event-hmac"))
|
||||
key := make([]byte, 32)
|
||||
hk.Read(key)
|
||||
// Compute the expected HMAC.
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(eventJSON)
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ package drift
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
type mockTransport struct {
|
||||
@@ -463,3 +468,44 @@ func TestNsForPath(t *testing.T) {
|
||||
t.Errorf("nsForPath = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-140 / F18 drift event authentication test ---
|
||||
|
||||
// TestVerifyEventSignature verifies HMAC verification works.
|
||||
func TestVerifyEventSignature(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
peerID := "peer-1"
|
||||
eventJSON := []byte(`{"event_id":"EVT-123","path":"/etc/traefik/orca.yaml","status":"changed"}`)
|
||||
// Compute a valid signature.
|
||||
hk := hkdf.New(sha256.New, masterKey, []byte(peerID), []byte("orca-drift-event-hmac"))
|
||||
key := make([]byte, 32)
|
||||
hk.Read(key)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(eventJSON)
|
||||
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !VerifyEventSignature(eventJSON, sig, masterKey, peerID) {
|
||||
t.Error("valid signature should verify")
|
||||
}
|
||||
// Wrong key.
|
||||
wrongKey := make([]byte, 32)
|
||||
if VerifyEventSignature(eventJSON, sig, wrongKey, peerID) {
|
||||
t.Error("wrong key should fail")
|
||||
}
|
||||
// Wrong peer.
|
||||
if VerifyEventSignature(eventJSON, sig, masterKey, "wrong-peer") {
|
||||
t.Error("wrong peer should fail")
|
||||
}
|
||||
// Tampered event.
|
||||
tampered := append([]byte{}, eventJSON...)
|
||||
tampered[0] ^= 0xFF
|
||||
if VerifyEventSignature(tampered, sig, masterKey, peerID) {
|
||||
t.Error("tampered event should fail")
|
||||
}
|
||||
// Empty signature.
|
||||
if VerifyEventSignature(eventJSON, "", masterKey, peerID) {
|
||||
t.Error("empty signature should fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user