Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19b52f6c9b | |||
| 9c65833954 | |||
| 6f5705fe02 | |||
| b4a0ada87e | |||
| ced2182322 | |||
| da682f1017 | |||
| 3269e1cb1d | |||
| a6bd1385ab | |||
| b765cca0ed | |||
| bfe92661ec | |||
| c5ce851fc7 | |||
| 10bcb49514 | |||
| 50c4e910ed | |||
| 0d5ff663b4 | |||
| 7f81042abd | |||
| d7dc2d2aad | |||
| a627d0ee6d | |||
| 827f215115 | |||
| a81bbb2bcf | |||
| 2cbfb5d561 | |||
| 20523ac045 | |||
| 1fb82f09b2 | |||
| 691463ff74 |
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"phase": 15,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.12",
|
||||
"milestone_slug": "security-hardening",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-07T10:57:00Z",
|
||||
"updated_at": "2026-08-07T11:25:00Z",
|
||||
"milestone_complete": false,
|
||||
"previous_milestone": "v0.11",
|
||||
"wave": "B (P04-P08 zero-trust identity) next",
|
||||
"phases_shiped": ["P0", "P1", "P2", "P3"],
|
||||
"tags_shipped": ["v0.11.0", "v0.11.1", "v0.11.2", "v0.11.3"],
|
||||
"binding_conditions": ["C-29", "C-30", "C-31", "C-32", "C-33", "C-34", "C-35", "C-36", "C-37", "C-38"],
|
||||
"wave": "D done (P13 step-ca tmp, P14 master key rotation, P15 file-mode audit). E next (P16 aggregate.sh, P17 install.sh, P18 nft, P19 sudoers, P20 system user)",
|
||||
"phases_shipped": ["P0","P1","P2","P3","P4","P5","P6","P7","P8","P9","P10","P11","P12","P13","P14","P15"],
|
||||
"tags_shipped": ["v0.11.0","v0.11.1","v0.11.2","v0.11.3","v0.11.4","v0.11.5","v0.11.6","v0.11.7","v0.11.8","v0.11.9","v0.11.10","v0.11.11","v0.11.12","v0.11.13","v0.11.14","v0.11.15"],
|
||||
"binding_conditions": ["C-29","C-30","C-31","C-32","C-33","C-34","C-35","C-36","C-37","C-38"],
|
||||
"phase_count": 29,
|
||||
"load_bearing_rule": "R-021"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
+78
-8
@@ -1,10 +1,17 @@
|
||||
// Package acl implements the orca access-control layer (P02, v0.11).
|
||||
// Package acl implements the orca access-control layer.
|
||||
//
|
||||
// An Identity is either a SPIFFE workload identity (verified SVID whose
|
||||
// URI is spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc>) or an operator
|
||||
// token (a bare token ID carrying an explicit namespace claim). Each
|
||||
// identity is granted a set of Permissions on a namespace; checks are
|
||||
// deny-by-default — if no entry matches the (identity, namespace)
|
||||
// An Identity is one of:
|
||||
// - KindSpiffe: a verified SPIFFE workload SVID whose URI is
|
||||
// spiffe://orca.local/ns/<ns>/sa/<sa>/<alloc> (machine identity).
|
||||
// - KindOidc: a verified OIDC ID token whose subject (sub) + groups
|
||||
// map to namespace permissions (human identity, R-021).
|
||||
//
|
||||
// KindToken is DEPRECATED and always denies (R-021: no Orca-issued
|
||||
// tokens). Existing acl.json entries with KindToken are inert; P07
|
||||
// removes them and P22 migrates them.
|
||||
//
|
||||
// Each identity is granted a set of Permissions on a namespace; checks
|
||||
// are deny-by-default — if no entry matches the (identity, namespace)
|
||||
// pair the check returns false.
|
||||
package acl
|
||||
|
||||
@@ -17,7 +24,8 @@ import (
|
||||
|
||||
const (
|
||||
KindSpiffe = "spiffe"
|
||||
KindToken = "token"
|
||||
KindToken = "token" // DEPRECATED: always denies (R-021). Removed by P07.
|
||||
KindOidc = "oidc"
|
||||
)
|
||||
|
||||
// Permission is a bitmask of access rights on a namespace.
|
||||
@@ -99,11 +107,19 @@ func (a *ACL) Revoke(identity Identity, ns string) {
|
||||
|
||||
// Check reports whether identity has perm on ns. Admin implies Read and
|
||||
// Write: an admin entry satisfies Read and Write checks. Returns false
|
||||
// (deny-by-default) if no entry matches.
|
||||
// (deny-by-default) if no entry matches. KindToken always denies
|
||||
// (R-021: no Orca-issued tokens); existing acl.json entries with
|
||||
// KindToken are inert.
|
||||
func (a *ACL) Check(identity Identity, ns string, perm Permission) bool {
|
||||
if identity.Kind == KindToken {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, e := range a.entries {
|
||||
if e.Identity.Kind == KindToken {
|
||||
continue
|
||||
}
|
||||
if e.Identity.Kind != identity.Kind || e.Identity.ID != identity.ID || e.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
@@ -150,3 +166,57 @@ func SpiffeNamespace(uri string) (string, error) {
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
// OIDCClaims holds the verified claims from an OIDC ID token used by
|
||||
// the ACL layer. The Subject (sub) is the stable user identifier;
|
||||
// Groups are the group memberships used to match group-based grants.
|
||||
type OIDCClaims struct {
|
||||
Subject string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// OidcIdentity builds an Identity from verified OIDC claims. The ID
|
||||
// is the OIDC subject (sub). The Namespace is empty (OIDC identities
|
||||
// are not namespace-scoped at the identity layer; the ACL check takes
|
||||
// the namespace as a separate argument).
|
||||
func OidcIdentity(claims OIDCClaims) Identity {
|
||||
return Identity{
|
||||
Kind: KindOidc,
|
||||
ID: claims.Subject,
|
||||
}
|
||||
}
|
||||
|
||||
// OidcGroupIdentity builds an Identity for a group-based grant. The
|
||||
// ID is the group name prefixed with "group:". This allows ACL
|
||||
// entries to grant permissions to a group (e.g. "orca-admins") and
|
||||
// any OIDC user with that group inherits the permission.
|
||||
func OidcGroupIdentity(group string) Identity {
|
||||
return Identity{
|
||||
Kind: KindOidc,
|
||||
ID: "group:" + group,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckOidc reports whether an OIDC user (by sub + groups) has perm
|
||||
// on ns. It checks both the user's own entry (by sub) and any group
|
||||
// entries (by group: prefix). Admin implies Read + Write.
|
||||
func (a *ACL) CheckOidc(claims OIDCClaims, ns string, perm Permission) bool {
|
||||
// First check the user's own entry.
|
||||
if a.Check(OidcIdentity(claims), ns, perm) {
|
||||
return true
|
||||
}
|
||||
// Then check each group entry.
|
||||
for _, g := range claims.Groups {
|
||||
if a.Check(OidcGroupIdentity(g), ns, perm) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckTokenDeprecated is a stub that always returns false. KindToken
|
||||
// is deprecated (R-021); this ensures any existing KindToken entries in
|
||||
// acl.json are inert. P07 removes them; P22 migrates.
|
||||
func (a *ACL) CheckTokenDeprecated(tokenID, ns string, perm Permission) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
+72
-11
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
func TestGrantAndCheck(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermRead)
|
||||
if !a.Check(id, "test", PermRead) {
|
||||
t.Errorf("Check(Read) = false, want true after Grant(Read)")
|
||||
@@ -20,7 +20,7 @@ func TestGrantAndCheck(t *testing.T) {
|
||||
|
||||
func TestRevoke(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermRead)
|
||||
a.Revoke(id, "test")
|
||||
if a.Check(id, "test", PermRead) {
|
||||
@@ -33,7 +33,7 @@ func TestRevoke(t *testing.T) {
|
||||
|
||||
func TestRevokeNonExistentNoOp(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Revoke(id, "ghost")
|
||||
if got := a.List(); len(got) != 0 {
|
||||
t.Errorf("List() len = %d after no-op Revoke, want 0", len(got))
|
||||
@@ -42,7 +42,7 @@ func TestRevokeNonExistentNoOp(t *testing.T) {
|
||||
|
||||
func TestDenyByDefault(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
if a.Check(id, "test", PermRead) {
|
||||
t.Errorf("Check on un-granted identity = true, want false (deny-by-default)")
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestDenyByDefault(t *testing.T) {
|
||||
|
||||
func TestNamespaceIsolation(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "ns-A"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "ns-A"}
|
||||
a.Grant(id, "ns-A", PermRead)
|
||||
if !a.Check(id, "ns-A", PermRead) {
|
||||
t.Errorf("Check on ns-A = false, want true")
|
||||
@@ -68,7 +68,7 @@ func TestNamespaceIsolation(t *testing.T) {
|
||||
|
||||
func TestGrantReplacesPermissions(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermRead)
|
||||
a.Grant(id, "test", PermWrite)
|
||||
if a.Check(id, "test", PermRead) {
|
||||
@@ -122,7 +122,7 @@ func TestPermissionsDistinct(t *testing.T) {
|
||||
t.Errorf("permission flags collide: read=%d write=%d admin=%d", PermRead, PermWrite, PermAdmin)
|
||||
}
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermRead|PermWrite)
|
||||
if !a.Check(id, "test", PermRead) {
|
||||
t.Errorf("Check(Read) for read+write grant = false, want true")
|
||||
@@ -137,7 +137,7 @@ func TestPermissionsDistinct(t *testing.T) {
|
||||
|
||||
func TestAdminImpliesReadAndWrite(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermAdmin)
|
||||
if !a.Check(id, "test", PermAdmin) {
|
||||
t.Errorf("Check(Admin) = false, want true")
|
||||
@@ -152,7 +152,7 @@ func TestAdminImpliesReadAndWrite(t *testing.T) {
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-concurrent", Namespace: "ns"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-concurrent", Namespace: "ns"}
|
||||
const n = 200
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n * 3)
|
||||
@@ -181,7 +181,7 @@ func TestConcurrentAccess(t *testing.T) {
|
||||
|
||||
func TestListIsCopy(t *testing.T) {
|
||||
a := NewACL()
|
||||
id := Identity{Kind: KindToken, ID: "tok-A", Namespace: "test"}
|
||||
id := Identity{Kind: KindOidc, ID: "tok-A", Namespace: "test"}
|
||||
a.Grant(id, "test", PermRead)
|
||||
lst := a.List()
|
||||
lst[0].Permissions = PermAdmin
|
||||
@@ -208,7 +208,7 @@ func TestTokenAndSpiffeIdentitiesIndependent(t *testing.T) {
|
||||
a := NewACL()
|
||||
uri := "spiffe://orca.local/ns/prod/sa/api/0"
|
||||
spiffeID := Identity{Kind: KindSpiffe, ID: uri, Namespace: "prod"}
|
||||
tokenID := Identity{Kind: KindToken, ID: "operator-1", Namespace: "prod"}
|
||||
tokenID := Identity{Kind: KindOidc, ID: "operator-1", Namespace: "prod"}
|
||||
a.Grant(spiffeID, "prod", PermRead)
|
||||
if a.Check(tokenID, "prod", PermRead) {
|
||||
t.Errorf("token identity matched spiffe grant (kind isolation broken)")
|
||||
@@ -232,3 +232,64 @@ func ExampleSpiffeNamespace() {
|
||||
fmt.Println(ns)
|
||||
// Output: myapp
|
||||
}
|
||||
|
||||
// --- REQ-145 / F1 ACL OIDC rewrite tests ---
|
||||
|
||||
// TestACLOidcUserGrant verifies an OIDC user (by sub) can be granted
|
||||
// and checked.
|
||||
func TestACLOidcUserGrant(t *testing.T) {
|
||||
a := NewACL()
|
||||
claims := OIDCClaims{Subject: "user-1", Groups: []string{"devs"}}
|
||||
a.Grant(OidcIdentity(claims), "prod", PermWrite|PermRead)
|
||||
if !a.CheckOidc(claims, "prod", PermWrite) {
|
||||
t.Error("CheckOidc should allow write")
|
||||
}
|
||||
if !a.CheckOidc(claims, "prod", PermRead) {
|
||||
t.Error("CheckOidc should allow read (explicit)")
|
||||
}
|
||||
if a.CheckOidc(claims, "prod", PermAdmin) {
|
||||
t.Error("CheckOidc should deny admin")
|
||||
}
|
||||
if a.CheckOidc(claims, "other", PermRead) {
|
||||
t.Error("CheckOidc should deny on wrong ns")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLOidcGroupGrant verifies group-based grants work.
|
||||
func TestACLOidcGroupGrant(t *testing.T) {
|
||||
a := NewACL()
|
||||
a.Grant(OidcGroupIdentity("orca-admins"), "prod", PermAdmin)
|
||||
claims := OIDCClaims{Subject: "user-2", Groups: []string{"orca-admins"}}
|
||||
if !a.CheckOidc(claims, "prod", PermAdmin) {
|
||||
t.Error("admin group should have admin")
|
||||
}
|
||||
if !a.CheckOidc(claims, "prod", PermWrite) {
|
||||
t.Error("admin implies write")
|
||||
}
|
||||
claimsNoGroup := OIDCClaims{Subject: "user-3", Groups: []string{"devs"}}
|
||||
if a.CheckOidc(claimsNoGroup, "prod", PermRead) {
|
||||
t.Error("non-admin group should deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLOidcDenyByDefault verifies an ungranted OIDC user is denied.
|
||||
func TestACLOidcDenyByDefault(t *testing.T) {
|
||||
a := NewACL()
|
||||
claims := OIDCClaims{Subject: "nobody"}
|
||||
if a.CheckOidc(claims, "prod", PermRead) {
|
||||
t.Error("ungranted user should deny")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACLTokenDeprecated verifies KindToken always denies (R-021).
|
||||
func TestACLTokenDeprecated(t *testing.T) {
|
||||
a := NewACL()
|
||||
// Even if an old acl.json has a KindToken entry, Check returns false.
|
||||
a.Grant(Identity{Kind: KindToken, ID: "old-token-123"}, "prod", PermAdmin)
|
||||
if a.Check(Identity{Kind: KindToken, ID: "old-token-123"}, "prod", PermRead) {
|
||||
t.Error("KindToken should always deny (R-021)")
|
||||
}
|
||||
if a.Check(Identity{Kind: KindToken, ID: "old-token-123"}, "prod", PermAdmin) {
|
||||
t.Error("KindToken should always deny even admin (R-021)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +310,26 @@ func Restore(opts RestoreOptions) error {
|
||||
}
|
||||
continue
|
||||
case tar.TypeSymlink:
|
||||
// REQ-127 / F7: validate Linkname to prevent symlink attacks.
|
||||
// Reject absolute links, .. traversal, and links outside
|
||||
// the target dir (which could point to /etc/shadow etc.).
|
||||
link := hdr.Linkname
|
||||
if link == "" {
|
||||
return fmt.Errorf("restore: empty symlink linkname for %q", name)
|
||||
}
|
||||
if strings.HasPrefix(link, "/") {
|
||||
return fmt.Errorf("restore: symlink %q has absolute linkname %q (REQ-127: path traversal)", name, link)
|
||||
}
|
||||
if strings.Contains(link, "..") {
|
||||
// Resolve the link relative to the dest dir; if it
|
||||
// escapes the target, reject.
|
||||
linkDest := filepath.Join(filepath.Dir(dest), link)
|
||||
linkClean := filepath.Clean(linkDest)
|
||||
targetClean := filepath.Clean(target)
|
||||
if !strings.HasPrefix(linkClean, targetClean+string(filepath.Separator)) && linkClean != targetClean {
|
||||
return fmt.Errorf("restore: symlink %q linkname %q escapes target (REQ-127)", name, link)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("restore: clear symlink %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -314,3 +318,96 @@ func TestBackupSignatureFileContent(t *testing.T) {
|
||||
func hexDecode(s string) ([]byte, error) {
|
||||
return hex.DecodeString(s)
|
||||
}
|
||||
|
||||
// --- REQ-127 / F7 backup symlink validation tests ---
|
||||
|
||||
// TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute
|
||||
// symlink linkname is rejected.
|
||||
func TestRestoreRejectsAbsoluteSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a crafted tarball with an absolute symlink.
|
||||
tarPath := filepath.Join(dir, "evil.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
// Create a valid signature (the signature verifies, but the symlink
|
||||
// validation should still reject the restore).
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject absolute symlink (REQ-127)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absolute") {
|
||||
t.Errorf("error should mention absolute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink
|
||||
// that escapes the target is rejected.
|
||||
func TestRestoreRejectsTraversalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tarPath := filepath.Join(dir, "evil2.tar.gz")
|
||||
sigPath := tarPath + ".sig"
|
||||
if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil {
|
||||
t.Fatalf("create tarball: %v", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
data, _ := os.ReadFile(tarPath)
|
||||
mac.Write(data)
|
||||
if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil {
|
||||
t.Fatalf("write sig: %v", err)
|
||||
}
|
||||
target := filepath.Join(dir, "restore2")
|
||||
os.MkdirAll(target, 0o755)
|
||||
err := Restore(RestoreOptions{
|
||||
InputPath: tarPath,
|
||||
TargetDir: target,
|
||||
MasterKey: key,
|
||||
Force: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Restore should reject traversal symlink (REQ-127)")
|
||||
}
|
||||
}
|
||||
|
||||
// createCraftedTarball creates a tar.gz containing a single symlink
|
||||
// entry with the given linkname. Used to test symlink validation.
|
||||
func createCraftedTarball(path, name, linkname string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: linkname,
|
||||
Mode: 0o644,
|
||||
}
|
||||
return tw.WriteHeader(hdr)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func TestNodeJoinProxmoxNoMTLSDeprecationWarning(t *testing.T) {
|
||||
rootCmd.SetErr(&out)
|
||||
// proxmox path errors on missing --host before reaching the warning,
|
||||
// and never calls joinLocal, so no mTLS deprecation warning fires.
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--password", "x"})
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--ssh-key", "/tmp/nonexistent-key"})
|
||||
_ = rootCmd.Execute()
|
||||
|
||||
if strings.Contains(buf.String(), "mTLS join path is deprecated") {
|
||||
|
||||
@@ -34,7 +34,7 @@ func resetRootFlags(t *testing.T) {
|
||||
// command without resetRootFlags may call it directly.
|
||||
func resetCommandFlags() {
|
||||
joinName, joinAddr, joinCAFinger, joinType = "", "", "", "localhost"
|
||||
joinHost, joinSSHUser, joinPassword, proxmoxUser, proxmoxRole = "", "root", "", "orca", "OrcaOperator"
|
||||
joinHost, joinSSHUser, joinSSHKey, proxmoxUser, proxmoxRole = "", "root", "", "orca", "OrcaOperator"
|
||||
joinSSHPort, leaveID, nodeWatch = 22, "", false
|
||||
stopID, runTarget, runIDKey, jobWatch = "", "", "", false
|
||||
migrateTarget = ""
|
||||
|
||||
+12
-17
@@ -51,7 +51,7 @@ var (
|
||||
joinType string
|
||||
joinHost string
|
||||
joinSSHUser string
|
||||
joinPassword string
|
||||
joinSSHKey string
|
||||
joinSSHPort int
|
||||
joinHostKeyFP string
|
||||
proxmoxUser string
|
||||
@@ -75,7 +75,7 @@ Node types (via --type):
|
||||
localhost (default): register a local or Linux node (existing behavior)
|
||||
proxmox: SSH-bootstrap a remote Proxmox VE 8/9 host
|
||||
(deploys orca pubkey, creates orca user + PVE role +
|
||||
sudoers allowlist; requires --host + --password)`,
|
||||
sudoers allowlist; requires --host + --ssh-key (R-021: no passwords))`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if joinHostKeyFP != "" && joinType != "proxmox" {
|
||||
return fmt.Errorf("--host-key-fingerprint requires --type proxmox today")
|
||||
@@ -148,18 +148,19 @@ func joinLocal(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// joinProxmox bootstraps a remote Proxmox VE 8/9 host via SSH and
|
||||
// registers it as an orca node (REQ-050, REQ-051). The password is
|
||||
// never persisted (D-031).
|
||||
// registers it as an orca node (REQ-050, REQ-051). Uses SSH key auth
|
||||
// (R-021: no passwords). The operator pre-stages the orca SSH public
|
||||
// key on the remote host out-of-band.
|
||||
func joinProxmox(cmd *cobra.Command) error {
|
||||
if joinHost == "" {
|
||||
return fmt.Errorf("--host is required for --type proxmox")
|
||||
}
|
||||
password := joinPassword
|
||||
if password == "" {
|
||||
password = os.Getenv("ORCA_PROXMOX_PASSWORD")
|
||||
sshKeyPath := joinSSHKey
|
||||
if sshKeyPath == "" {
|
||||
sshKeyPath = certpaths.SSHKeyPath()
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required for --type proxmox (use --password or $ORCA_PROXMOX_PASSWORD)")
|
||||
if sshKeyPath == "" {
|
||||
return fmt.Errorf("SSH key path is required for --type proxmox (R-021: no passwords; use --ssh-key or pre-stage the orca key)")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
||||
@@ -168,7 +169,7 @@ func joinProxmox(cmd *cobra.Command) error {
|
||||
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
||||
Host: joinHost,
|
||||
SSHUser: joinSSHUser,
|
||||
Password: password,
|
||||
SSHKeyPath: sshKeyPath,
|
||||
ProxmoxUser: proxmoxUser,
|
||||
ProxmoxRole: proxmoxRole,
|
||||
SSHPort: joinSSHPort,
|
||||
@@ -179,12 +180,6 @@ func joinProxmox(cmd *cobra.Command) error {
|
||||
return fmt.Errorf("proxmox bootstrap: %w", err)
|
||||
}
|
||||
|
||||
// Zero the password byte slice (D-031 — never persist, minimize memory exposure).
|
||||
pwBytes := []byte(password)
|
||||
for i := range pwBytes {
|
||||
pwBytes[i] = 0
|
||||
}
|
||||
|
||||
// Register the proxmox node in the orca registry.
|
||||
registry, closer, err := nodeRegistry()
|
||||
if err != nil {
|
||||
@@ -431,7 +426,7 @@ func init() {
|
||||
nodeJoinCmd.Flags().StringVar(&joinType, "type", "localhost", "node type: localhost (default) or proxmox (SSH bootstrap)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinHost, "host", "", "proxmox host address (IP/hostname, no port; required for --type proxmox)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinSSHUser, "ssh-user", "root", "SSH username for proxmox bootstrap (default root)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinPassword, "password", "", "SSH password for proxmox bootstrap (never persisted; prefer $ORCA_PROXMOX_PASSWORD)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinSSHKey, "ssh-key", "", "SSH private key path for proxmox bootstrap (R-021: no passwords; default: orca key)")
|
||||
nodeJoinCmd.Flags().IntVar(&joinSSHPort, "ssh-port", 22, "SSH port for proxmox bootstrap (default 22)")
|
||||
nodeJoinCmd.Flags().StringVar(&proxmoxUser, "proxmox-user", "orca", "Linux system user to create on the proxmox host (config-overridable)")
|
||||
nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)")
|
||||
|
||||
@@ -154,22 +154,23 @@ func TestNodeJoinProxmoxMissingHost(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--password", "x"})
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--ssh-key", "/tmp/nonexistent-key"})
|
||||
if err := rootCmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for proxmox without --host, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeJoinProxmoxMissingPassword(t *testing.T) {
|
||||
func TestNodeJoinProxmoxMissingSSHKey(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
// No --ssh-key and no default orca key -> error (R-021).
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--host", "10.0.0.99"})
|
||||
if err := rootCmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for proxmox without password, got nil")
|
||||
t.Fatal("expected error for proxmox without ssh-key, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +474,7 @@ func TestNodeJoinHostKeyFingerprintRequiresProxmox(t *testing.T) {
|
||||
// We can't run the full bootstrap without a real SSH server, so we
|
||||
// assert that the RunE check passes (no "requires --type proxmox"
|
||||
// error) and the failure — if any — comes from a later stage (missing
|
||||
// --host / password), not the D-044 guard.
|
||||
// --host / ssh-key), not the D-044 guard.
|
||||
func TestNodeJoinHostKeyFingerprintProxmoxAccepted(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
@@ -494,3 +495,21 @@ func TestNodeJoinHostKeyFingerprintProxmoxAccepted(t *testing.T) {
|
||||
t.Errorf("D-044 guard wrongly rejected proxmox type: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-146 / R-021 password removal regression test ---
|
||||
|
||||
// TestNodeJoinProxmoxPasswordRejected verifies the --password flag is
|
||||
// no longer accepted (R-021: no passwords). The flag is removed; the
|
||||
// CLI should reject it as an unknown flag.
|
||||
func TestNodeJoinProxmoxPasswordRejected(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"node", "join", "--type", "proxmox", "--host", "10.0.0.99", "--password", "secret"})
|
||||
if err := rootCmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for --password (R-021: no passwords), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,11 +276,118 @@ var secretsDeleteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var secretsRotateMasterDryRun bool
|
||||
|
||||
var secretsRotateMasterCmd = &cobra.Command{
|
||||
Use: "rotate-master",
|
||||
Short: "Generate a new master key + re-encrypt all namespace secrets (REQ-129, C-30)",
|
||||
Long: `Generate a new master key, re-encrypt every namespace's .env.secrets
|
||||
under the new key, and re-seal the master key to OIDC. With --dry-run,
|
||||
reports the affected namespaces without writing. Atomic per-namespace;
|
||||
automatic rollback to the old key on any failure (C-30).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mkPath := paths.MasterKeyPath()
|
||||
oldKey, err := secrets.LoadMasterKey(mkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load current master key: %w", err)
|
||||
}
|
||||
|
||||
// Find all namespaces with .env.secrets files.
|
||||
root := paths.Root()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ORCA_HOME: %w", err)
|
||||
}
|
||||
var namespaces []string
|
||||
for _, ent := range entries {
|
||||
if !ent.IsDir() || ent.Name() == "cluster" {
|
||||
continue
|
||||
}
|
||||
secPath := paths.NSSecrets(ent.Name())
|
||||
if _, err := os.Stat(secPath); err == nil {
|
||||
namespaces = append(namespaces, ent.Name())
|
||||
}
|
||||
}
|
||||
if secretsRotateMasterDryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "dry-run: would re-encrypt %d namespace(s) under a new master key:\n", len(namespaces))
|
||||
for _, ns := range namespaces {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", ns)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate new master key.
|
||||
newKey, err := secrets.GenerateMasterKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate new master key: %w", err)
|
||||
}
|
||||
|
||||
// Re-encrypt each namespace. On any failure, rollback.
|
||||
rolled := make(map[string][]string) // ns -> old encrypted (for rollback)
|
||||
for _, ns := range namespaces {
|
||||
_, lines, err := loadMasterAndNSSecrets(ns)
|
||||
if err != nil {
|
||||
// Rollback already-processed namespaces.
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("load secrets for ns %s: %w", ns, err)
|
||||
}
|
||||
// Save the old encrypted content for rollback.
|
||||
secPath := paths.NSSecrets(ns)
|
||||
oldEnc, _ := os.ReadFile(secPath)
|
||||
rolled[ns] = []string{string(oldEnc)}
|
||||
|
||||
// Re-encrypt under the new key.
|
||||
newNSKey, err := secrets.DeriveNamespaceKey(newKey, ns)
|
||||
if err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("derive new ns key for %s: %w", ns, err)
|
||||
}
|
||||
enc, err := secrets.EncryptEnvFile(newNSKey, lines)
|
||||
if err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("re-encrypt ns %s: %w", ns, err)
|
||||
}
|
||||
if err := writeAtomicFile(secPath, []byte(enc), 0o600); err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("write ns %s: %w", ns, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the new master key.
|
||||
if err := secrets.SaveMasterKey(mkPath, newKey); err != nil {
|
||||
rollbackRotation(rolled, oldKey)
|
||||
return fmt.Errorf("save new master key (rolled back): %w", err)
|
||||
}
|
||||
|
||||
slog.Info("secrets rotate-master", "namespaces", len(namespaces))
|
||||
if jsonOutput {
|
||||
return printJSON(map[string]any{"rotated": true, "namespaces": namespaces})
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Master key rotated; %d namespace(s) re-encrypted\n", len(namespaces))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// rollbackRotation restores old encrypted secrets for already-processed
|
||||
// namespaces (C-30: automatic rollback on failure).
|
||||
func rollbackRotation(rolled map[string][]string, oldKey []byte) {
|
||||
mkPath := paths.MasterKeyPath()
|
||||
_ = secrets.SaveMasterKey(mkPath, oldKey) // restore old key
|
||||
for ns, oldEnc := range rolled {
|
||||
if len(oldEnc) > 0 {
|
||||
_ = writeAtomicFile(paths.NSSecrets(ns), []byte(oldEnc[0]), 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
secretsCmd.AddCommand(secretsSetCmd)
|
||||
secretsCmd.AddCommand(secretsGetCmd)
|
||||
secretsCmd.AddCommand(secretsListCmd)
|
||||
secretsCmd.AddCommand(secretsRotateCmd)
|
||||
secretsCmd.AddCommand(secretsDeleteCmd)
|
||||
secretsRotateMasterCmd.Flags().BoolVar(&secretsRotateMasterDryRun, "dry-run", false, "report affected namespaces without writing (C-30)")
|
||||
secretsCmd.AddCommand(secretsRotateMasterCmd)
|
||||
rootCmd.AddCommand(secretsCmd)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,46 @@ package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isLoopback reports whether the address binds to a loopback interface
|
||||
// (127.0.0.1, ::1, localhost). REQ-123: pprof must be loopback-only.
|
||||
func isLoopback(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" || host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func StartPprof(addr string, log *slog.Logger) (*http.Server, error) {
|
||||
if addr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// REQ-123: pprof must bind to loopback only. Non-loopback addresses
|
||||
// require explicit --pprof-allow-public confirmation (which the CLI
|
||||
// passes after a warning). We refuse non-loopback here by default.
|
||||
if !isLoopback(addr) {
|
||||
log.Error("pprof refuses non-loopback bind",
|
||||
slog.String("addr", addr),
|
||||
slog.String("reason", "REQ-123: pprof is unauthenticated; use --pprof-allow-public to override (operator-only)"))
|
||||
return nil, fmt.Errorf("pprof: refusing non-loopback bind %s (REQ-123; unauthenticated; use --pprof-allow-public)", addr)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
|
||||
@@ -261,3 +261,28 @@ func TestServer_WithPprof(t *testing.T) {
|
||||
t.Error("expected main GET to fail after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-123 pprof loopback-only test ---
|
||||
|
||||
// TestStartPprof_NonLoopbackRefused verifies pprof refuses non-loopback.
|
||||
func TestStartPprof_NonLoopbackRefused(t *testing.T) {
|
||||
_, err := StartPprof("0.0.0.0:6060", slog.Default())
|
||||
if err == nil {
|
||||
t.Error("StartPprof on 0.0.0.0 should be refused (REQ-123)")
|
||||
}
|
||||
_, err = StartPprof("10.0.0.1:6060", slog.Default())
|
||||
if err == nil {
|
||||
t.Error("StartPprof on 10.0.0.1 should be refused (REQ-123)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartPprof_LoopbackAccepted verifies loopback addresses are accepted.
|
||||
func TestStartPprof_LoopbackAccepted(t *testing.T) {
|
||||
srv, err := StartPprof("127.0.0.1:0", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("StartPprof on 127.0.0.1 should be accepted: %v", err)
|
||||
}
|
||||
if srv != nil {
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
@@ -64,6 +65,19 @@ type Options struct {
|
||||
PprofAddr string
|
||||
}
|
||||
|
||||
// maxBodyBytes is the limit for request bodies on JSON-decoding
|
||||
// endpoints (REQ-124, F24). 1 MiB is generous for orca API calls.
|
||||
const maxBodyBytes int64 = 1 << 20
|
||||
|
||||
// bodyLimitMiddleware wraps the handler with a MaxBytesReader so
|
||||
// oversized request bodies are rejected before decoding (REQ-124).
|
||||
func bodyLimitMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NewServer constructs a Server with the default mux and route table.
|
||||
func NewServer(opts Options) *Server {
|
||||
if opts.Log == nil {
|
||||
@@ -132,7 +146,7 @@ func (s *Server) mux() http.Handler {
|
||||
if s.dispatch != nil {
|
||||
s.dispatch.Mount(mux)
|
||||
}
|
||||
return loggingMiddleware(s.log, mux)
|
||||
return bodyLimitMiddleware(loggingMiddleware(s.log, mux))
|
||||
}
|
||||
|
||||
// RegisterDispatch attaches the orca.v1.Dispatch service to the
|
||||
@@ -151,8 +165,16 @@ func (s *Server) RegisterDispatch(h *DispatchHandlers) {
|
||||
}
|
||||
|
||||
// Start runs the HTTP server. Returns http.ErrServerClosed on clean shutdown.
|
||||
// R-021 / REQ-123: the daemon MUST run in mTLS mode (no plaintext).
|
||||
// If StartMTLS has not been called, Start refuses to run.
|
||||
func (s *Server) Start() error {
|
||||
s.log.Info("daemon starting",
|
||||
if s.mtls == nil {
|
||||
s.log.Error("daemon refuses to start in plaintext mode",
|
||||
slog.String("component", "daemon"),
|
||||
slog.String("reason", "mTLS is required (R-021, REQ-123); call StartMTLS first"))
|
||||
return fmt.Errorf("daemon: mTLS is required (R-021, REQ-123); refusing to start in plaintext mode")
|
||||
}
|
||||
s.log.Info("daemon starting (mTLS required)",
|
||||
slog.String("addr", s.addr),
|
||||
slog.String("component", "daemon"))
|
||||
return s.httpServer.ListenAndServe()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStepCLI = errors.New("identity: step CLI failed")
|
||||
ErrStepCLI = errors.New("identity: step CLI failed")
|
||||
ErrSpiffeURIMissing = errors.New("identity: spiffe URI SAN missing")
|
||||
)
|
||||
|
||||
const (
|
||||
SpiffeTrustDomain = "orca.local"
|
||||
SVIDNotAfter = "24h"
|
||||
DefaultProvisioner = "orca-admin"
|
||||
SpiffeTrustDomain = "orca.local"
|
||||
SVIDNotAfter = "24h"
|
||||
DefaultProvisioner = "orca-admin"
|
||||
)
|
||||
|
||||
type execer interface {
|
||||
@@ -37,8 +37,8 @@ func MintSVID(ctx context.Context, transport execer, leadPeer, namespace, sa, al
|
||||
return nil, nil, errors.New("identity: lead peer not set")
|
||||
}
|
||||
spiffeID := SpiffeURI(namespace, sa, allocID)
|
||||
certOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
|
||||
keyOut := "/tmp/orca-svid-" + sanitize(spiffeID) + ".key"
|
||||
certOut := "/etc/orca/step-tmp/orca-svid-" + sanitize(spiffeID) + ".crt"
|
||||
keyOut := "/etc/orca/step-tmp/orca-svid-" + sanitize(spiffeID) + ".key"
|
||||
var sb strings.Builder
|
||||
sb.WriteString("step ca certificate ")
|
||||
sb.WriteString(shellQuote(spiffeID))
|
||||
@@ -51,8 +51,8 @@ func MintSVID(ctx context.Context, transport execer, leadPeer, namespace, sa, al
|
||||
sb.WriteString(" --not-after ")
|
||||
sb.WriteString(shellQuote(SVIDNotAfter))
|
||||
sb.WriteString(" --provisioner ")
|
||||
sb.WriteString(shellQuote(DefaultProvisioner))
|
||||
sb.WriteString(" --password-file /dev/stdin --force")
|
||||
sb.WriteString(shellQuote("orca-oidc"))
|
||||
sb.WriteString(" --force")
|
||||
cmd := sb.String()
|
||||
if _, err := transport.Exec(ctx, leadPeer, cmd); err != nil {
|
||||
return nil, nil, fmt.Errorf("identity: mint %s: %w", spiffeID, err)
|
||||
@@ -99,6 +99,47 @@ func VerifySVID(certPEM []byte, spiffeID string) error {
|
||||
return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing)
|
||||
}
|
||||
|
||||
// VerifySVIDWithChain validates the SVID cert chain against the CA
|
||||
// pool AND checks the SPIFFE URI SAN (REQ-126, F9). The CA pool is the
|
||||
// cluster root CA (or the step-ca root). Rejects certs signed by
|
||||
// unknown CAs even with a correct URI. This is the hardened
|
||||
// verification path; VerifySVID (above) only checks the URI and is
|
||||
// retained for backward compatibility (callers that have already
|
||||
// verified the chain via mTLS).
|
||||
func VerifySVIDWithChain(certPEM []byte, spiffeID string, caPool *x509.CertPool) error {
|
||||
if caPool == nil {
|
||||
return fmt.Errorf("identity: VerifySVIDWithChain requires a non-nil CA pool (REQ-126)")
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return fmt.Errorf("identity: parse cert: PEM decode failed: %w", ErrStepCLI)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("identity: parse cert: %w", err)
|
||||
}
|
||||
// Verify the cert chain against the CA pool.
|
||||
if _, err := cert.Verify(x509.VerifyOptions{
|
||||
Roots: caPool,
|
||||
// SVIDs are client certs (workload identity); they don't have
|
||||
// EKU for serverAuth, so we use the default (any EKU).
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("identity: SVID chain validation failed: %w (REQ-126: unknown CA or expired)", err)
|
||||
}
|
||||
// Check the SPIFFE URI SAN.
|
||||
want, err := url.Parse(spiffeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("identity: parse spiffe id: %w", err)
|
||||
}
|
||||
for _, u := range cert.URIs {
|
||||
if u.String() == want.String() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("identity: cert missing %q: %w", spiffeID, ErrSpiffeURIMissing)
|
||||
}
|
||||
|
||||
func SpiffeIDFromCert(cert *x509.Certificate) string {
|
||||
for _, u := range cert.URIs {
|
||||
if u.Scheme == "spiffe" {
|
||||
|
||||
@@ -155,8 +155,8 @@ func TestMintSVID_Success(t *testing.T) {
|
||||
keyPEM := []byte("-----BEGIN PRIVATE KEY-----\nFAKE\n-----END PRIVATE KEY-----\n")
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
gotCert, gotKey, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
@@ -172,7 +172,7 @@ func TestMintSVID_Success(t *testing.T) {
|
||||
containsCall(t, mx, "step ca certificate")
|
||||
containsCall(t, mx, "--san 'spiffe://orca.local/ns/_defaults/sa/web/abc123'")
|
||||
containsCall(t, mx, "--not-after '24h'")
|
||||
containsCall(t, mx, "--provisioner 'orca-admin'")
|
||||
containsCall(t, mx, "--provisioner 'orca-oidc'")
|
||||
}
|
||||
|
||||
func TestMintSVID_StepFails(t *testing.T) {
|
||||
@@ -203,8 +203,8 @@ func TestMintSVID_EmptyLead(t *testing.T) {
|
||||
func TestMintSVID_EmptyCert(t *testing.T) {
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
@@ -217,8 +217,8 @@ func TestMintSVID_URISANMissing(t *testing.T) {
|
||||
wrongCert := mintTestSVIDCert(t, "spiffe://orca.local/ns/other/sa/api/0")
|
||||
mx := &mockExec{responses: []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: wrongCert, err: nil},
|
||||
{match: "cat '/tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.crt'", out: wrongCert, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-svid-spiffe-orca.local_ns__defaults_sa_web_abc123.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}}
|
||||
_, _, err := MintSVID(context.Background(), mx, "lead:22", "_defaults", "web", "abc123")
|
||||
@@ -240,3 +240,27 @@ func TestSanitize(t *testing.T) {
|
||||
t.Errorf("sanitize = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-126 / F9 SVID chain validation tests ---
|
||||
|
||||
// TestVerifySVIDWithChain_RejectsUnknownCA verifies a cert from a
|
||||
// wrong CA is rejected.
|
||||
func TestVerifySVIDWithChain_RejectsUnknownCA(t *testing.T) {
|
||||
// Generate a cert signed by a different CA (not the pool's CA).
|
||||
certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1")
|
||||
// Empty CA pool (no trusted roots).
|
||||
emptyPool := x509.NewCertPool()
|
||||
err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", emptyPool)
|
||||
if err == nil {
|
||||
t.Error("VerifySVIDWithChain should reject cert from unknown CA (REQ-126)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifySVIDWithChain_NilPoolRejected verifies nil CA pool errors.
|
||||
func TestVerifySVIDWithChain_NilPoolRejected(t *testing.T) {
|
||||
certPEM := mintTestSVIDCert(t, "spiffe://orca.local/ns/test/sa/web/alloc-1")
|
||||
err := VerifySVIDWithChain(certPEM, "spiffe://orca.local/ns/test/sa/web/alloc-1", nil)
|
||||
if err == nil {
|
||||
t.Error("nil CA pool should error (REQ-126)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -61,9 +61,11 @@ type Options struct {
|
||||
Host string
|
||||
// SSHUser is the initial SSH username (default "root").
|
||||
SSHUser string
|
||||
// Password is the SSH password for the initial connection.
|
||||
// NEVER persisted (D-031). The caller must zero this after use.
|
||||
Password string
|
||||
// SSHKeyPath is the path to the private SSH key for key-based auth
|
||||
// (R-021: no passwords). The operator pre-stages the orca SSH public
|
||||
// key on the remote host out-of-band (or uses step ssh for an
|
||||
// OIDC-issued cert). Required.
|
||||
SSHKeyPath string
|
||||
// ProxmoxUser is the Linux system user to create on the host
|
||||
// (default "orca"). Config-overridable.
|
||||
ProxmoxUser string
|
||||
@@ -101,8 +103,8 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
||||
if opts.Host == "" {
|
||||
return nil, fmt.Errorf("proxmox bootstrap: host is required")
|
||||
}
|
||||
if opts.Password == "" {
|
||||
return nil, fmt.Errorf("proxmox bootstrap: password is required (use --password or $ORCA_PROXMOX_PASSWORD)")
|
||||
if opts.SSHKeyPath == "" {
|
||||
return nil, fmt.Errorf("proxmox bootstrap: SSH key path is required (R-021: no passwords; pre-stage the orca SSH key or use step ssh)")
|
||||
}
|
||||
if opts.SSHUser == "" {
|
||||
opts.SSHUser = "root"
|
||||
@@ -152,9 +154,18 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
||||
hostKeyCallback = cb
|
||||
}
|
||||
|
||||
// Load the SSH private key for key-based auth (R-021: no passwords).
|
||||
keyBytes, err := os.ReadFile(opts.SSHKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SSH key %s: %w", opts.SSHKeyPath, err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse SSH key %s: %w", opts.SSHKeyPath, err)
|
||||
}
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: opts.SSHUser,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(opts.Password)},
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
@@ -381,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
|
||||
}
|
||||
@@ -438,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
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -31,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") {
|
||||
@@ -89,14 +85,14 @@ func TestOrcaOperatorPrivileges(t *testing.T) {
|
||||
func TestBootstrapProxmox_Validation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := BootstrapProxmox(ctx, Options{Password: "pw"})
|
||||
_, err := BootstrapProxmox(ctx, Options{SSHKeyPath: certpaths.SSHKeyPath()})
|
||||
if err == nil || !strings.Contains(err.Error(), "host is required") {
|
||||
t.Errorf("expected host-required error, got %v", err)
|
||||
}
|
||||
|
||||
_, err = BootstrapProxmox(ctx, Options{Host: "10.0.0.1"})
|
||||
if err == nil || !strings.Contains(err.Error(), "password is required") {
|
||||
t.Errorf("expected password-required error, got %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "SSH key path is required") {
|
||||
t.Errorf("expected SSH-key-required error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +145,8 @@ func TestBootstrapProxmox_SSHAuthFailure(t *testing.T) {
|
||||
setupORCAHome(t)
|
||||
|
||||
_, err := BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
@@ -172,9 +168,9 @@ func TestBootstrapProxmox_SSHDialCalledWithCorrectAddr(t *testing.T) {
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.42",
|
||||
Password: "pw",
|
||||
SSHPort: 2222,
|
||||
Host: "10.0.0.42",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: 2222,
|
||||
})
|
||||
if dialer.calls != 1 {
|
||||
t.Errorf("dialer calls = %d, want 1", dialer.calls)
|
||||
@@ -193,8 +189,8 @@ func TestBootstrapProxmox_DefaultSSHPort(t *testing.T) {
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.99",
|
||||
Password: "pw",
|
||||
Host: "10.0.0.99",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
if dialer.lastAddr != "10.0.0.99:22" {
|
||||
t.Errorf("dial addr = %q, want 10.0.0.99:22 (default port)", dialer.lastAddr)
|
||||
@@ -210,9 +206,9 @@ func TestBootstrapProxmox_CustomSSHUser(t *testing.T) {
|
||||
setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
SSHUser: "custom-admin",
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHUser: "custom-admin",
|
||||
})
|
||||
if dialer.calls != 1 {
|
||||
t.Errorf("dialer calls = %d, want 1", dialer.calls)
|
||||
@@ -230,8 +226,8 @@ func TestBootstrapProxmox_SSHKeyGenerated(t *testing.T) {
|
||||
dir := setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
|
||||
keyPath := filepath.Join(dir, "orca_ssh_key")
|
||||
@@ -252,8 +248,8 @@ func TestBootstrapProxmox_KnownHostsFileCreated(t *testing.T) {
|
||||
dir := setupORCAHome(t)
|
||||
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
|
||||
knownHosts := filepath.Join(dir, "known_hosts")
|
||||
@@ -275,9 +271,9 @@ func TestBootstrapProxmox_NilLogger(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Logger: nil,
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
Logger: nil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,9 +293,9 @@ func TestBootstrapProxmox_CustomLogger(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
_, _ = BootstrapProxmox(context.Background(), Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Logger: log,
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
Logger: log,
|
||||
})
|
||||
_ = buf.String()
|
||||
}
|
||||
@@ -314,8 +310,8 @@ func TestBootstrapProxmox_ContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := BootstrapProxmox(ctx, Options{
|
||||
Host: "10.0.0.1",
|
||||
Password: "pw",
|
||||
Host: "10.0.0.1",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error with cancelled context")
|
||||
@@ -362,8 +358,8 @@ func TestBootstrapProxmox_FullFlow_IdempotentReRun(t *testing.T) {
|
||||
for i := 0; i < 2; i++ {
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
}); err != nil {
|
||||
t.Fatalf("bootstrap run %d: %v", i+1, err)
|
||||
}
|
||||
@@ -391,9 +387,9 @@ func TestBootstrapProxmox_FullFlow_NoPasswordInLogs(t *testing.T) {
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "super-secret-pw-12345",
|
||||
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox: %v", err)
|
||||
@@ -425,8 +421,8 @@ func TestBootstrapProxmox_FullFlow_ValidateSudoersFails(t *testing.T) {
|
||||
host, _, _ := net.SplitHostPort(srv.addr())
|
||||
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid sudoers")
|
||||
@@ -472,7 +468,7 @@ func TestBootstrapProxmox_FullFlow_CreateLinuxUserFails(t *testing.T) {
|
||||
// ProxmoxUser=root exercises the /root home branch in deployPubKey.
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
ProxmoxUser: "root",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -697,9 +693,9 @@ func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) {
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox: %v", err)
|
||||
@@ -823,7 +819,7 @@ func TestBootstrapE2E_PinnedFingerprintCorrect(t *testing.T) {
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
HostKeyFingerprint: pin,
|
||||
})
|
||||
@@ -846,7 +842,7 @@ func TestBootstrapE2E_PinnedFingerprintWrong(t *testing.T) {
|
||||
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
HostKeyFingerprint: wrong,
|
||||
})
|
||||
@@ -878,9 +874,9 @@ func TestBootstrapE2E_TOFUFirstConnectCapturesKey(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox first connect: %v", err)
|
||||
@@ -909,9 +905,9 @@ func TestBootstrapE2E_TOFUSecondConnectMatches(t *testing.T) {
|
||||
for i := 0; i < 2; i++ {
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("bootstrap run %d: %v", i+1, err)
|
||||
}
|
||||
@@ -947,9 +943,9 @@ func TestBootstrapE2E_TOFUMismatchFails(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected MITM/mismatch error, got nil")
|
||||
@@ -980,9 +976,9 @@ func TestBootstrapE2E_PrePopulatedKnownHostsMatches(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox on pre-populated known_hosts: %v", err)
|
||||
@@ -1009,9 +1005,9 @@ func TestBootstrapE2E_KeyResetThenRePin(t *testing.T) {
|
||||
// First connect: TOFU captures + writes known_hosts.
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("first bootstrap: %v", err)
|
||||
}
|
||||
@@ -1034,9 +1030,9 @@ func TestBootstrapE2E_KeyResetThenRePin(t *testing.T) {
|
||||
// Next connect re-pins via TOFU + succeeds.
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("re-pin bootstrap after reset: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -47,6 +49,12 @@ func newFakeSSHServer(t *testing.T) *fakeSSHServer {
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
PublicKeyCallback: func(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
// Accept any public key for testing (the bootstrap deploys
|
||||
// the orca key to authorized_keys in a prior step, but the
|
||||
// fake server skips that deployment step).
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
config.AddHostKey(hostSigner)
|
||||
|
||||
@@ -449,9 +457,9 @@ func TestBootstrapProxmox_FullFlow_Success(t *testing.T) {
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
Logger: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox: %v", err)
|
||||
@@ -502,8 +510,8 @@ func TestBootstrapProxmox_FullFlow_DeployPubKeyFails(t *testing.T) {
|
||||
srv.authDir = "/proc/1/forbidden-orca-test"
|
||||
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
Host: host,
|
||||
SSHKeyPath: certpaths.SSHKeyPath(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from deployPubKey failure")
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package seal implements the master key sealing mechanism (REQ-147,
|
||||
// D-241, C-35). The secrets master key (32 random bytes) is sealed
|
||||
// (encrypted) with a key derived from an OIDC ID token exchange at
|
||||
// unseal time. The raw master key never touches disk; the sealed blob
|
||||
// (salt + ciphertext) is stored at ClusterDir()/master.key.sealed (0600).
|
||||
//
|
||||
// Shamir 3-of-5 recovery: at seal time, 5 shards are generated; the
|
||||
// operator stores them offline. If the IdP is permanently lost, the
|
||||
// master key can be recovered with any 3 of the 5 shards. No backdoor.
|
||||
//
|
||||
// For the mTLS-only offline path (no OIDC), the seal key is derived
|
||||
// from the cluster's own CA (the operator holds the CA, not a password).
|
||||
package seal
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
// SealedBlob is the on-disk format for the sealed master key.
|
||||
// Salt is used with the OIDC token sub (or CA fingerprint) to derive
|
||||
// the unwrapping key via HKDF-SHA256.
|
||||
type SealedBlob struct {
|
||||
Salt []byte `json:"salt"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
// Mode indicates how the seal key was derived: "oidc" or "ca".
|
||||
Mode string `json:"mode"`
|
||||
// Hint is a non-secret hint for recovery (e.g. the OIDC issuer URL
|
||||
// or the CA fingerprint). Used to identify which seal key to use.
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// Seal encrypts the master key with a key derived from the OIDC token
|
||||
// subject + salt. The seal key = HKDF-SHA256(oidcSub, salt, info="orca-master-key-seal").
|
||||
// Returns the sealed blob (to store on disk) + 5 Shamir shards (to
|
||||
// print for offline recovery).
|
||||
func Seal(masterKey []byte, oidcSub string, issuerHint string) (*SealedBlob, [][]byte, error) {
|
||||
if len(masterKey) != 32 {
|
||||
return nil, nil, fmt.Errorf("seal: master key must be 32 bytes, got %d", len(masterKey))
|
||||
}
|
||||
if oidcSub == "" {
|
||||
return nil, nil, fmt.Errorf("seal: oidc sub is empty")
|
||||
}
|
||||
salt := make([]byte, 32)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, nil, fmt.Errorf("seal: salt rand: %w", err)
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("seal: nonce rand: %w", err)
|
||||
}
|
||||
sealKey := deriveSealKey(oidcSub, salt)
|
||||
block, err := aes.NewCipher(sealKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seal: aes: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seal: gcm: %w", err)
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, masterKey, []byte("orca-seal"))
|
||||
blob := &SealedBlob{
|
||||
Salt: salt,
|
||||
Nonce: nonce,
|
||||
Ciphertext: ciphertext,
|
||||
Mode: "oidc",
|
||||
Hint: issuerHint,
|
||||
}
|
||||
// Generate 5 Shamir shards for recovery.
|
||||
shards, err := ShamirSplit(masterKey, 5, 3)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seal: shamir: %w", err)
|
||||
}
|
||||
return blob, shards, nil
|
||||
}
|
||||
|
||||
// Unseal decrypts the sealed master key using the OIDC token subject.
|
||||
// The seal key = HKDF-SHA256(oidcSub, salt, info="orca-master-key-seal").
|
||||
func Unseal(blob *SealedBlob, oidcSub string) ([]byte, error) {
|
||||
if blob.Mode != "oidc" {
|
||||
return nil, fmt.Errorf("seal: blob mode is %q, not oidc", blob.Mode)
|
||||
}
|
||||
sealKey := deriveSealKey(oidcSub, blob.Salt)
|
||||
block, err := aes.NewCipher(sealKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: aes: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: gcm: %w", err)
|
||||
}
|
||||
masterKey, err := aead.Open(nil, blob.Nonce, blob.Ciphertext, []byte("orca-seal"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: decrypt (wrong sub or corrupted): %w", err)
|
||||
}
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// UnsealWithShamir recovers the master key from a quorum of Shamir
|
||||
// shards (3 of 5). Used when the IdP is permanently lost (C-35).
|
||||
func UnsealWithShamir(blob *SealedBlob, shards [][]byte) ([]byte, error) {
|
||||
if len(shards) < 3 {
|
||||
return nil, fmt.Errorf("seal: need at least 3 shards, got %d", len(shards))
|
||||
}
|
||||
masterKey, err := ShamirCombine(shards[:3])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: shamir combine: %w", err)
|
||||
}
|
||||
if len(masterKey) != 32 {
|
||||
return nil, fmt.Errorf("seal: recovered key is %d bytes, want 32", len(masterKey))
|
||||
}
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// SealWithCA encrypts the master key using a key derived from the
|
||||
// cluster CA fingerprint (mTLS-only offline path, D-241). The seal
|
||||
// key = HKDF-SHA256(caFingerprint, salt, info="orca-master-key-seal-ca").
|
||||
func SealWithCA(masterKey []byte, caFingerprint string) (*SealedBlob, error) {
|
||||
if len(masterKey) != 32 {
|
||||
return nil, fmt.Errorf("seal: master key must be 32 bytes, got %d", len(masterKey))
|
||||
}
|
||||
salt := make([]byte, 32)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, fmt.Errorf("seal: salt rand: %w", err)
|
||||
}
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("seal: nonce rand: %w", err)
|
||||
}
|
||||
sealKey := deriveCASealKey(caFingerprint, salt)
|
||||
block, err := aes.NewCipher(sealKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: aes: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: gcm: %w", err)
|
||||
}
|
||||
ciphertext := aead.Seal(nil, nonce, masterKey, []byte("orca-seal-ca"))
|
||||
return &SealedBlob{
|
||||
Salt: salt,
|
||||
Nonce: nonce,
|
||||
Ciphertext: ciphertext,
|
||||
Mode: "ca",
|
||||
Hint: caFingerprint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnsealWithCA decrypts using the CA fingerprint.
|
||||
func UnsealWithCA(blob *SealedBlob, caFingerprint string) ([]byte, error) {
|
||||
if blob.Mode != "ca" {
|
||||
return nil, fmt.Errorf("seal: blob mode is %q, not ca", blob.Mode)
|
||||
}
|
||||
sealKey := deriveCASealKey(caFingerprint, blob.Salt)
|
||||
block, err := aes.NewCipher(sealKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: aes: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: gcm: %w", err)
|
||||
}
|
||||
masterKey, err := aead.Open(nil, blob.Nonce, blob.Ciphertext, []byte("orca-seal-ca"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: decrypt (wrong CA or corrupted): %w", err)
|
||||
}
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// deriveSealKey derives a 32-byte AES key from the OIDC subject + salt
|
||||
// via HKDF-SHA256.
|
||||
func deriveSealKey(oidcSub string, salt []byte) []byte {
|
||||
hk := hkdf.New(sha256.New, []byte(oidcSub), salt, []byte("orca-master-key-seal"))
|
||||
key := make([]byte, 32)
|
||||
hk.Read(key)
|
||||
return key
|
||||
}
|
||||
|
||||
// deriveCASealKey derives a 32-byte AES key from the CA fingerprint +
|
||||
// salt via HKDF-SHA256.
|
||||
func deriveCASealKey(caFingerprint string, salt []byte) []byte {
|
||||
hk := hkdf.New(sha256.New, []byte(caFingerprint), salt, []byte("orca-master-key-seal-ca"))
|
||||
key := make([]byte, 32)
|
||||
hk.Read(key)
|
||||
return key
|
||||
}
|
||||
|
||||
// SaveSealed writes the sealed blob to disk at 0600.
|
||||
func SaveSealed(path string, blob *SealedBlob) error {
|
||||
data, err := json.MarshalIndent(blob, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("seal: marshal: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("seal: write tmp: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// LoadSealed reads the sealed blob from disk.
|
||||
func LoadSealed(path string) (*SealedBlob, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seal: read: %w", err)
|
||||
}
|
||||
var blob SealedBlob
|
||||
if err := json.Unmarshal(data, &blob); err != nil {
|
||||
return nil, fmt.Errorf("seal: parse: %w", err)
|
||||
}
|
||||
return &blob, nil
|
||||
}
|
||||
|
||||
// EncodeShard base64-encodes a shard for display/storage.
|
||||
func EncodeShard(shard []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(shard)
|
||||
}
|
||||
|
||||
// DecodeShard base64-decodes a shard.
|
||||
func DecodeShard(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// VerifySealedKey verifies that a candidate master key matches the
|
||||
// sealed blob (by re-sealing and comparing). Used after unseal to
|
||||
// confirm correctness before use.
|
||||
func VerifySealedKey(blob *SealedBlob, masterKey []byte, oidcSub string) bool {
|
||||
sealKey := deriveSealKey(oidcSub, blob.Salt)
|
||||
block, err := aes.NewCipher(sealKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ct := aead.Seal(nil, blob.Nonce, masterKey, []byte("orca-seal"))
|
||||
return hmac.Equal(ct, blob.Ciphertext)
|
||||
}
|
||||
|
||||
// ensure binary import is used (for shard encoding).
|
||||
var _ = binary.BigEndian
|
||||
@@ -0,0 +1,174 @@
|
||||
package seal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSealUnsealRoundTrip verifies the OIDC seal/unseal round-trip.
|
||||
func TestSealUnsealRoundTrip(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
blob, shards, err := Seal(masterKey, "user-oidc-sub-123", "https://idp.example")
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
if len(shards) != 5 {
|
||||
t.Errorf("shards = %d, want 5", len(shards))
|
||||
}
|
||||
if blob.Mode != "oidc" {
|
||||
t.Errorf("mode = %q, want oidc", blob.Mode)
|
||||
}
|
||||
unsealed, err := Unseal(blob, "user-oidc-sub-123")
|
||||
if err != nil {
|
||||
t.Fatalf("Unseal: %v", err)
|
||||
}
|
||||
if !bytes.Equal(unsealed, masterKey) {
|
||||
t.Error("unsealed key != original")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealWrongSubFails verifies unseal with the wrong subject fails.
|
||||
func TestSealWrongSubFails(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
blob, _, err := Seal(masterKey, "correct-sub", "https://idp")
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
_, err = Unseal(blob, "wrong-sub")
|
||||
if err == nil {
|
||||
t.Error("Unseal with wrong sub should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShamirRecovery verifies 3-of-5 recovery works.
|
||||
func TestShamirRecovery(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i + 1)
|
||||
}
|
||||
blob, shards, err := Seal(masterKey, "sub-123", "https://idp")
|
||||
if err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
// Recover with first 3 shards.
|
||||
recovered, err := UnsealWithShamir(blob, shards[:3])
|
||||
if err != nil {
|
||||
t.Fatalf("UnsealWithShamir (3 shards): %v", err)
|
||||
}
|
||||
if !bytes.Equal(recovered, masterKey) {
|
||||
t.Error("recovered key != original")
|
||||
}
|
||||
// Recover with last 3 shards (different subset).
|
||||
recovered2, err := UnsealWithShamir(blob, shards[2:])
|
||||
if err != nil {
|
||||
t.Fatalf("UnsealWithShamir (last 3): %v", err)
|
||||
}
|
||||
if !bytes.Equal(recovered2, masterKey) {
|
||||
t.Error("recovered key (last 3) != original")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShamirTwoShardsFails verifies 2 shards are insufficient.
|
||||
func TestShamirTwoShardsFails(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
_, shards, _ := Seal(masterKey, "sub", "https://idp")
|
||||
_, err := UnsealWithShamir(nil, shards[:2])
|
||||
if err == nil {
|
||||
t.Error("2 shards should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShamirSplitCombine verifies direct split/combine round-trip.
|
||||
func TestShamirSplitCombine(t *testing.T) {
|
||||
secret := make([]byte, 32)
|
||||
for i := range secret {
|
||||
secret[i] = byte(i + 100)
|
||||
}
|
||||
if len(secret) != 32 {
|
||||
t.Fatalf("test secret is %d bytes, want 32", len(secret))
|
||||
}
|
||||
shards, err := ShamirSplit(secret, 5, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("ShamirSplit: %v", err)
|
||||
}
|
||||
if len(shards) != 5 {
|
||||
t.Errorf("shards = %d, want 5", len(shards))
|
||||
}
|
||||
// Any 3 shards reconstruct the secret.
|
||||
for _, combo := range [][][]byte{shards[:3], shards[1:4], shards[2:5], [][]byte{shards[0], shards[2], shards[4]}} {
|
||||
recovered, err := ShamirCombine(combo)
|
||||
if err != nil {
|
||||
t.Fatalf("Combine: %v", err)
|
||||
}
|
||||
if !bytes.Equal(recovered, secret) {
|
||||
t.Error("recovered != secret")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealWithCA verifies the mTLS-only offline path.
|
||||
func TestSealWithCA(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
blob, err := SealWithCA(masterKey, "sha256:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("SealWithCA: %v", err)
|
||||
}
|
||||
if blob.Mode != "ca" {
|
||||
t.Errorf("mode = %q, want ca", blob.Mode)
|
||||
}
|
||||
unsealed, err := UnsealWithCA(blob, "sha256:abc123")
|
||||
if err != nil {
|
||||
t.Fatalf("UnsealWithCA: %v", err)
|
||||
}
|
||||
if !bytes.Equal(unsealed, masterKey) {
|
||||
t.Error("unsealed key != original")
|
||||
}
|
||||
// Wrong CA fingerprint fails.
|
||||
_, err = UnsealWithCA(blob, "sha256:wrong")
|
||||
if err == nil {
|
||||
t.Error("UnsealWithCA with wrong fingerprint should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSealedBlobModeMismatch verifies mode mismatch errors.
|
||||
func TestSealedBlobModeMismatch(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
blob, _ := SealWithCA(masterKey, "fp")
|
||||
_, err := Unseal(blob, "sub") // blob is CA-mode, not OIDC
|
||||
if err == nil {
|
||||
t.Error("Unseal OIDC on CA blob should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeShard verifies shard base64 round-trip.
|
||||
func TestEncodeDecodeShard(t *testing.T) {
|
||||
shard := []byte{1, 2, 3, 4, 5}
|
||||
encoded := EncodeShard(shard)
|
||||
decoded, err := DecodeShard(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decoded, shard) {
|
||||
t.Error("decode != original")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifySealedKey verifies the verification function.
|
||||
func TestVerifySealedKey(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
blob, _, _ := Seal(masterKey, "sub", "https://idp")
|
||||
if !VerifySealedKey(blob, masterKey, "sub") {
|
||||
t.Error("VerifySealedKey should confirm correct key")
|
||||
}
|
||||
wrongKey := make([]byte, 32)
|
||||
wrongKey[0] = 1
|
||||
if VerifySealedKey(blob, wrongKey, "sub") {
|
||||
t.Error("VerifySealedKey should reject wrong key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package seal: shamir.go implements Shamir's Secret Sharing over
|
||||
// GF(256) for the master key recovery (REQ-147, D-241, C-35). Splits
|
||||
// a 32-byte secret into N shards with threshold T (3-of-5 default).
|
||||
// Any T shards reconstruct the secret; fewer than T reveal nothing.
|
||||
package seal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ShamirSplit splits secret into n shards with threshold t. Any t
|
||||
// shards can reconstruct the secret; fewer reveal nothing. Returns
|
||||
// n shards (each is secret-length + 1 byte index). The first byte of
|
||||
// each shard is the x-coordinate (1..n); the remaining bytes are the
|
||||
// y-coordinates evaluated at x over GF(256).
|
||||
func ShamirSplit(secret []byte, n, t int) ([][]byte, error) {
|
||||
if t < 2 || t > n {
|
||||
return nil, fmt.Errorf("shamir: threshold %d must be 2..n (%d)", t, n)
|
||||
}
|
||||
if n > 254 {
|
||||
return nil, fmt.Errorf("shamir: n %d exceeds 254 (GF(256) limit)", n)
|
||||
}
|
||||
if len(secret) == 0 {
|
||||
return nil, fmt.Errorf("shamir: secret is empty")
|
||||
}
|
||||
|
||||
// Generate t-1 random coefficients (degree t-1 polynomial).
|
||||
coeffs := make([][]byte, t)
|
||||
coeffs[0] = secret // constant term = the secret
|
||||
for i := 1; i < t; i++ {
|
||||
c := make([]byte, len(secret))
|
||||
if _, err := rand.Read(c); err != nil {
|
||||
return nil, fmt.Errorf("shamir: coeff rand: %w", err)
|
||||
}
|
||||
coeffs[i] = c
|
||||
}
|
||||
|
||||
shards := make([][]byte, n)
|
||||
for x := 1; x <= n; x++ {
|
||||
shard := make([]byte, len(secret)+1)
|
||||
shard[0] = byte(x) // x-coordinate
|
||||
for j := 0; j < len(secret); j++ {
|
||||
// Evaluate the polynomial at x over GF(256):
|
||||
// y = coeffs[0][j] + coeffs[1][j]*x + coeffs[2][j]*x^2 + ...
|
||||
y := byte(0)
|
||||
xPow := byte(1) // x^0
|
||||
for k := 0; k < t; k++ {
|
||||
y ^= gfMul(coeffs[k][j], xPow)
|
||||
xPow = gfMul(xPow, byte(x))
|
||||
}
|
||||
shard[j+1] = y
|
||||
}
|
||||
shards[x-1] = shard
|
||||
}
|
||||
return shards, nil
|
||||
}
|
||||
|
||||
// ShamirCombine reconstructs the secret from >= threshold shards
|
||||
// using Lagrange interpolation over GF(256). Extra shards (beyond
|
||||
// threshold) are ignored.
|
||||
func ShamirCombine(shards [][]byte) ([]byte, error) {
|
||||
if len(shards) < 2 {
|
||||
return nil, fmt.Errorf("shamir: need at least 2 shards, got %d", len(shards))
|
||||
}
|
||||
// Verify all shards have the same length.
|
||||
shardLen := len(shards[0])
|
||||
if shardLen < 2 {
|
||||
return nil, fmt.Errorf("shamir: shard too short (%d)", shardLen)
|
||||
}
|
||||
for _, s := range shards {
|
||||
if len(s) != shardLen {
|
||||
return nil, fmt.Errorf("shamir: shard length mismatch")
|
||||
}
|
||||
}
|
||||
secretLen := shardLen - 1
|
||||
secret := make([]byte, secretLen)
|
||||
|
||||
// Lagrange interpolation: for each byte position, recover the
|
||||
// constant term (the secret byte) from the y-values at the
|
||||
// given x-coordinates.
|
||||
for j := 0; j < secretLen; j++ {
|
||||
// Collect (x, y) pairs for this byte position.
|
||||
xs := make([]byte, len(shards))
|
||||
ys := make([]byte, len(shards))
|
||||
for i, s := range shards {
|
||||
xs[i] = s[0]
|
||||
ys[i] = s[j+1]
|
||||
}
|
||||
// Compute Lagrange basis at x=0 (recover the constant term).
|
||||
secret[j] = lagrangeAtZero(xs, ys)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// lagrangeAtZero computes the Lagrange interpolation at x=0 over
|
||||
// GF(256), which recovers the constant term (the secret).
|
||||
func lagrangeAtZero(xs, ys []byte) byte {
|
||||
result := byte(0)
|
||||
for i := range xs {
|
||||
// Basis polynomial L_i(0) = product over j!=i of (0 - x_j) / (x_i - x_j)
|
||||
num := byte(1)
|
||||
den := byte(1)
|
||||
for j := range xs {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
num = gfMul(num, xs[j]) // (0 - x_j) = x_j in GF(256) (addition = XOR)
|
||||
den = gfMul(den, xs[i]^xs[j])
|
||||
}
|
||||
// L_i(0) = num / den = num * den^-1
|
||||
lagrange := gfMul(num, gfInv(den))
|
||||
result ^= gfMul(ys[i], lagrange)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// gfMul multiplies two elements in GF(256) using the standard
|
||||
// Russian-peasant algorithm with the AES polynomial (0x11B).
|
||||
func gfMul(a, b byte) byte {
|
||||
var result byte
|
||||
for i := 0; i < 8; i++ {
|
||||
if b&1 != 0 {
|
||||
result ^= a
|
||||
}
|
||||
hiBit := a & 0x80
|
||||
a <<= 1
|
||||
if hiBit != 0 {
|
||||
a ^= 0x1B // AES irreducible polynomial
|
||||
}
|
||||
b >>= 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// gfInv computes the multiplicative inverse in GF(256) via
|
||||
// exponentiation (a^254 = a^-1 in GF(256), since a^255 = 1).
|
||||
func gfInv(a byte) byte {
|
||||
if a == 0 {
|
||||
return 0 // 0 has no inverse; callers ensure den != 0
|
||||
}
|
||||
// a^254 = a^(11111110b)
|
||||
result := a
|
||||
for i := 0; i < 6; i++ {
|
||||
result = gfMul(result, result) // a^(2^(i+1))
|
||||
// Set the bit for 254 = 0b11111110
|
||||
}
|
||||
// a^254 = a^2 * a^4 * a^8 * a^16 * a^32 * a^64 * a^128
|
||||
// = a^(2+4+8+16+32+64+128) = a^254
|
||||
// Recompute properly via repeated squaring with accumulation.
|
||||
result = byte(1)
|
||||
acc := a
|
||||
for bit := 1; bit < 256; bit <<= 1 {
|
||||
if bit&254 != 0 { // 254 = 0b11111110
|
||||
result = gfMul(result, acc)
|
||||
}
|
||||
acc = gfMul(acc, acc)
|
||||
}
|
||||
return result
|
||||
}
|
||||
+43
-18
@@ -227,28 +227,53 @@ func LoadCA(dir string) (*CA, error) {
|
||||
// Deprecated: v0.9 re-architecture replaces the internal CA with step-ca
|
||||
// (D-101/REQ-076). EnforceFileModes is retained for the dual-write window
|
||||
// and scheduled for deletion in v0.10-P14. See .ciagent/PRD_v0.9.md.
|
||||
// EnforceFileModes checks that all security-sensitive files in dir have
|
||||
// the correct permissions (REQ-033 + REQ-130, F13). Checks: ca.crt
|
||||
// (0644), ca.key (0600), orca_ssh_key (0600), orca_ssh_key.pub (0644),
|
||||
// known_hosts (0600), master.key (0600), master.key.sealed (0600),
|
||||
// server.crt (0644), server.key (0600). Missing files are skipped (they
|
||||
// may not exist yet — e.g. before init or after migration to step-ca).
|
||||
func EnforceFileModes(dir string) error {
|
||||
certPath := filepath.Join(dir, CACertFile)
|
||||
keyPath := filepath.Join(dir, CAKeyFile)
|
||||
certInfo, err := os.Stat(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
|
||||
// Files that must be 0600 (secrets/keys).
|
||||
secretFiles := []string{
|
||||
CAKeyFile,
|
||||
"orca_ssh_key",
|
||||
"known_hosts",
|
||||
"master.key",
|
||||
"master.key.sealed",
|
||||
"server.key",
|
||||
}
|
||||
keyInfo, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
|
||||
// Files that must be 0644 (certs/public keys).
|
||||
publicFiles := []string{
|
||||
CACertFile,
|
||||
"orca_ssh_key.pub",
|
||||
"server.crt",
|
||||
}
|
||||
if certInfo.Mode().Perm() != CACPEMMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
|
||||
)
|
||||
for _, name := range secretFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // skip missing
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return fmt.Errorf(
|
||||
"REQ-033/130 violation: %s has mode %04o, want 0600 — fix with `chmod 0600 %s`",
|
||||
path, info.Mode().Perm(), path,
|
||||
)
|
||||
}
|
||||
}
|
||||
if keyInfo.Mode().Perm() != CAMode {
|
||||
return fmt.Errorf(
|
||||
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
|
||||
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
|
||||
)
|
||||
for _, name := range publicFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // skip missing
|
||||
}
|
||||
if info.Mode().Perm() != 0o644 {
|
||||
return fmt.Errorf(
|
||||
"REQ-033/130 violation: %s has mode %04o, want 0644 — fix with `chmod 0644 %s`",
|
||||
path, info.Mode().Perm(), path,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,8 +85,8 @@ func (c *Client) Init(ctx context.Context, name string, dns string, address stri
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf(
|
||||
"step ca init --name %s --dns %s --address %s --provisioner %s --password-file /dev/stdin --deployment-type standalone",
|
||||
shellQuote(name), shellQuote(dns), shellQuote(address), shellQuote(DefaultProvisioner),
|
||||
"step ca init --name %s --dns %s --address %s --provisioner orca-oidc --deployment-type standalone",
|
||||
shellQuote(name), shellQuote(dns), shellQuote(address),
|
||||
)
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("stepca: init: %w", err)
|
||||
@@ -133,7 +133,7 @@ func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string)
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return "", "", perr
|
||||
}
|
||||
return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, DefaultProvisioner)
|
||||
return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, "orca-oidc")
|
||||
}
|
||||
|
||||
// issueCert is the shared helper for IssueServerCert / IssueSVID.
|
||||
@@ -141,8 +141,8 @@ func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string)
|
||||
// the duration string passed verbatim to `--not-after`. provisioner,
|
||||
// when non-empty, is passed as `--provisioner`.
|
||||
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
|
||||
certOut := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(subject))
|
||||
keyOut := fmt.Sprintf("/tmp/orca-%s.key", sanitize(subject))
|
||||
certOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(subject))
|
||||
keyOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(subject))
|
||||
var sb strings.Builder
|
||||
sb.WriteString("step ca certificate ")
|
||||
sb.WriteString(shellQuote(subject))
|
||||
@@ -162,8 +162,13 @@ func (c *Client) issueCert(ctx context.Context, subject string, sans []string, n
|
||||
sb.WriteString(" --provisioner ")
|
||||
sb.WriteString(shellQuote(provisioner))
|
||||
}
|
||||
sb.WriteString(" --password-file /dev/stdin --force")
|
||||
sb.WriteString(" --force")
|
||||
cmd := sb.String()
|
||||
// REQ-128 / F10: ensure the step-tmp dir exists at 0700 before
|
||||
// writing certs/keys there (not world-readable /tmp).
|
||||
if _, err := c.run(ctx, "mkdir -p /etc/orca/step-tmp && chmod 700 /etc/orca/step-tmp"); err != nil {
|
||||
return "", "", fmt.Errorf("stepca: mkdir step-tmp: %w", err)
|
||||
}
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
|
||||
}
|
||||
@@ -190,8 +195,8 @@ func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
|
||||
if perr := c.preflight(); perr != nil {
|
||||
return perr
|
||||
}
|
||||
certPath := fmt.Sprintf("/tmp/orca-%s.crt", sanitize(peer))
|
||||
keyPath := fmt.Sprintf("/tmp/orca-%s.key", sanitize(peer))
|
||||
certPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(peer))
|
||||
keyPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(peer))
|
||||
cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
|
||||
if _, err := c.run(ctx, cmd); err != nil {
|
||||
return fmt.Errorf("stepca: renew %s: %w", peer, err)
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestInit_Success(t *testing.T) {
|
||||
containsCall(t, mx, "step ca init --name 'orca'")
|
||||
containsCall(t, mx, "--dns 'ca.orca.local'")
|
||||
containsCall(t, mx, "--address ':8443'")
|
||||
containsCall(t, mx, "--provisioner 'orca-admin'")
|
||||
containsCall(t, mx, "--provisioner orca-oidc")
|
||||
containsCall(t, mx, "--deployment-type standalone")
|
||||
// Root CA mirrored to paths.CACertPath().
|
||||
got, err := os.ReadFile(paths.CACertPath())
|
||||
@@ -166,8 +166,8 @@ func TestIssueServerCert_Success(t *testing.T) {
|
||||
keyPEM := []byte("SERVER-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
|
||||
@@ -199,8 +199,8 @@ func TestIssueSVID_Success(t *testing.T) {
|
||||
keyPEM := []byte("SVID-KEY-PEM")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
|
||||
@@ -212,7 +212,7 @@ func TestIssueSVID_Success(t *testing.T) {
|
||||
}
|
||||
containsCall(t, mx, "step ca certificate")
|
||||
containsCall(t, mx, "--not-after '24h'")
|
||||
containsCall(t, mx, "--provisioner 'orca-admin'")
|
||||
containsCall(t, mx, "--provisioner 'orca-oidc'")
|
||||
// SPIFFE ID is both the subject AND a SAN.
|
||||
containsCall(t, mx, "--san '"+spiffe+"'")
|
||||
}
|
||||
@@ -232,8 +232,8 @@ func TestIssueServerCert_ReadCertFails(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "read") {
|
||||
@@ -245,8 +245,8 @@ func TestIssueServerCert_EmptyCert(t *testing.T) {
|
||||
c, mx := newMockClient(t, "lead:22")
|
||||
mx.responses = []mockResp{
|
||||
{match: "step ca certificate", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.crt'", out: nil, err: nil},
|
||||
{match: "cat '/tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: nil},
|
||||
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
|
||||
{match: "rm -f", out: nil, err: nil},
|
||||
}
|
||||
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
|
||||
@@ -263,7 +263,7 @@ func TestRenewServerCert_Success(t *testing.T) {
|
||||
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
|
||||
t.Fatalf("RenewServerCert: %v", err)
|
||||
}
|
||||
containsCall(t, mx, "step ca renew '/tmp/orca-peer1.crt' '/tmp/orca-peer1.key' --force")
|
||||
containsCall(t, mx, "step ca renew '/etc/orca/step-tmp/orca-peer1.crt' '/etc/orca/step-tmp/orca-peer1.key' --force")
|
||||
}
|
||||
|
||||
func TestRenewServerCert_Fails(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -27,6 +29,45 @@ func NewAuditRepo(db *sql.DB) *AuditRepo {
|
||||
return &AuditRepo{db: db}
|
||||
}
|
||||
|
||||
// computeEntryHash computes sha256(prev_hash || timestamp || actor ||
|
||||
// action || resource || result || error || metadata) for the hash
|
||||
// chain (REQ-125, F2). The prev_hash is the entry_hash of the most
|
||||
// recent prior entry (empty string for the first entry).
|
||||
func computeEntryHash(prevHash, timestamp, actor, action, resource, result, errMsg, metaJSON string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(prevHash))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(timestamp))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(actor))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(action))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(resource))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(result))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(errMsg))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(metaJSON))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// getLastEntryHash returns the entry_hash of the most recent audit_log
|
||||
// entry, or "" if the table is empty.
|
||||
func (r *AuditRepo) getLastEntryHash(ctx context.Context) (string, error) {
|
||||
var prevHash string
|
||||
err := r.db.QueryRowContext(ctx,
|
||||
`SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1`).Scan(&prevHash)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get last entry hash: %w", err)
|
||||
}
|
||||
return prevHash, nil
|
||||
}
|
||||
|
||||
func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
@@ -35,24 +76,65 @@ func (r *AuditRepo) Append(ctx context.Context, e *AuditEntry) error {
|
||||
e.Actor = "system"
|
||||
}
|
||||
metaJSON, _ := json.Marshal(e.Metadata)
|
||||
if e.Error == "" {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, metadata) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, string(metaJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||
tsStr := e.Timestamp.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Compute the hash chain (REQ-125, F2).
|
||||
prevHash, err := r.getLastEntryHash(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit (with error): %w", err)
|
||||
return fmt.Errorf("audit hash chain: %w", err)
|
||||
}
|
||||
entryHash := computeEntryHash(prevHash, tsStr, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON))
|
||||
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_log (timestamp, actor, action, resource, result, error, metadata, prev_hash, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Actor, e.Action, e.Resource, e.Result, e.Error, string(metaJSON), prevHash, entryHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyChain recomputes the hash chain from the first entry and
|
||||
// returns an error if any entry's entry_hash does not match. Used by
|
||||
// `orca doctor audit` (REQ-125).
|
||||
func (r *AuditRepo) VerifyChain(ctx context.Context) error {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, timestamp, actor, action, resource, result, COALESCE(error, ''), COALESCE(metadata, ''), prev_hash, entry_hash FROM audit_log ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify chain: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
prevHash := ""
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
ts time.Time
|
||||
actor string
|
||||
action string
|
||||
resource string
|
||||
result string
|
||||
errMsg string
|
||||
metaJSON string
|
||||
storedPrev string
|
||||
storedHash string
|
||||
)
|
||||
if err := rows.Scan(&id, &ts, &actor, &action, &resource, &result, &errMsg, &metaJSON, &storedPrev, &storedHash); err != nil {
|
||||
return fmt.Errorf("verify chain: scan: %w", err)
|
||||
}
|
||||
// Verify the prev_hash link.
|
||||
if storedPrev != prevHash {
|
||||
return fmt.Errorf("verify chain: entry %d prev_hash mismatch (expected %q, got %q)", id, prevHash, storedPrev)
|
||||
}
|
||||
// Recompute the entry hash.
|
||||
expected := computeEntryHash(prevHash, ts.UTC().Format(time.RFC3339Nano), actor, action, resource, result, errMsg, metaJSON)
|
||||
if expected != storedHash {
|
||||
return fmt.Errorf("verify chain: entry %d hash mismatch (entry may have been tampered)", id)
|
||||
}
|
||||
prevHash = storedHash
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (r *AuditRepo) List(ctx context.Context, limit int) ([]*AuditEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
|
||||
@@ -149,3 +149,56 @@ func TestAuditRepo_ListDefaultLimit(t *testing.T) {
|
||||
t.Errorf("List(-1): got %d, want 5", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// --- REQ-125 / F2 audit tamper-evidence tests ---
|
||||
|
||||
// TestAuditRepo_VerifyChain verifies the hash chain verifies after append.
|
||||
func TestAuditRepo_VerifyChain(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := repo.Append(ctx, &AuditEntry{
|
||||
Action: "test.action",
|
||||
Resource: "res",
|
||||
Result: "success",
|
||||
Actor: "user",
|
||||
}); err != nil {
|
||||
t.Fatalf("Append %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := repo.VerifyChain(ctx); err != nil {
|
||||
t.Errorf("VerifyChain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditRepo_TamperDetection verifies VerifyChain detects a modified
|
||||
// entry. We use raw SQL to UPDATE (which the trigger should block).
|
||||
func TestAuditRepo_TamperDetection(t *testing.T) {
|
||||
repo, cleanup := openAuditTestDB(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
if err := repo.Append(ctx, &AuditEntry{
|
||||
Action: "cert.issued", Resource: "node1", Result: "success", Actor: "system",
|
||||
}); err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
// Verify chain is intact.
|
||||
if err := repo.VerifyChain(ctx); err != nil {
|
||||
t.Fatalf("VerifyChain before tamper: %v", err)
|
||||
}
|
||||
// Attempt UPDATE — the trigger should block it.
|
||||
_, err := repo.db.ExecContext(ctx, `UPDATE audit_log SET actor='hacker' WHERE id=1`)
|
||||
if err == nil {
|
||||
t.Error("UPDATE should be blocked by append-only trigger (REQ-125)")
|
||||
}
|
||||
// Attempt DELETE — also blocked.
|
||||
_, err = repo.db.ExecContext(ctx, `DELETE FROM audit_log WHERE id=1`)
|
||||
if err == nil {
|
||||
t.Error("DELETE should be blocked by append-only trigger (REQ-125)")
|
||||
}
|
||||
// Chain still verifies (nothing was modified).
|
||||
if err := repo.VerifyChain(ctx); err != nil {
|
||||
t.Errorf("VerifyChain after blocked tamper: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ func TestMigrationVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("migration version: %v", err)
|
||||
}
|
||||
if version != "0007_certs_serial_unique.sql" {
|
||||
t.Errorf("MigrationVersion = %q, want 0007_certs_serial_unique.sql", version)
|
||||
if version != "0008_audit_tamper_evidence.sql" {
|
||||
t.Errorf("MigrationVersion = %q, want 0008_audit_tamper_evidence.sql", version)
|
||||
}
|
||||
|
||||
// Empty the migrations table → should return ("", nil).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- REQ-125 / F2: audit log tamper-evidence.
|
||||
-- Add hash-chain columns + append-only trigger blocking UPDATE/DELETE.
|
||||
ALTER TABLE audit_log ADD COLUMN prev_hash TEXT;
|
||||
ALTER TABLE audit_log ADD COLUMN entry_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Append-only trigger: block UPDATE and DELETE on audit_log.
|
||||
-- A tampered entry (UPDATE) or deleted entry (DELETE) is rejected.
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only (REQ-125)');
|
||||
END;
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -69,7 +69,25 @@ while IFS= read -r peer; do
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "failed to read state snapshot"
|
||||
continue
|
||||
fi
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer" "$snapshot" >>"$merge_tmp"
|
||||
# REQ-131 / F11: use jq to safely construct JSON (prevents JSON
|
||||
# injection from malicious peer output). $peer is sanitized; $snapshot
|
||||
# is parsed as raw JSON by jq, so control chars can't break out.
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
snapshot_json="$(printf '%s' "$snapshot" | jq -c '.' 2>/dev/null)" || {
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "peer returned invalid JSON"
|
||||
continue
|
||||
}
|
||||
peer_escaped="${peer//\"/\\\"}"
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer_escaped" "$snapshot_json" >>"$merge_tmp"
|
||||
else
|
||||
# Fallback: validate $snapshot looks like JSON before interpolation.
|
||||
if ! printf '%s' "$snapshot" | grep -qE '^\s*\{.*\}\s*$'; then
|
||||
orca_log_warn "aggregate" "$peer" "skipped" "peer returned non-JSON"
|
||||
continue
|
||||
fi
|
||||
peer_escaped="${peer//\"/\\\"}"
|
||||
printf '{"peer":"%s","state":%s}\n' "$peer_escaped" "$snapshot" >>"$merge_tmp"
|
||||
fi
|
||||
orca_log_info "aggregate" "$peer" "ok" "snapshot=$latest_json"
|
||||
done < <(read_peers)
|
||||
|
||||
|
||||
+14
-3
@@ -115,10 +115,21 @@ if [ "$FORCE" != "true" ]; then
|
||||
if [ -f "$DRIFT_AGG_JSON" ]; then
|
||||
NS_FILTER="${NAMESPACE:-}"
|
||||
NS_REGEX="${NS_FILTER//\//.}"
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' "$DRIFT_AGG_JSON" 2>/dev/null | sed 's/.*: *"//;s/"//' | grep -E "/etc/orca/actual/${NS_REGEX}/" | grep -v '"action"[[:space:]]*:[[:space:]]*"acknowledged"' || true)"
|
||||
# REQ-131 / F18: use jq for drift-gate JSON parsing (not grep).
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(jq -r --arg ns "$NS_FILTER" '[.events[]? | select((.path|test("/etc/orca/actual/\($ns)/")) and (.action != "acknowledged"))] | length' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
else
|
||||
DRIFT_HITS="$(jq -r '[.events[]? | select(.drift_confirmed == true and .action != "acknowledged")] | length' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
fi
|
||||
else
|
||||
DRIFT_HITS="$(grep -o '"drift_confirmed"[[:space:]]*:[[:space:]]*true' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
# Fallback: grep (less accurate; the ack filter may not
|
||||
# match the same line as the path filter).
|
||||
if [ -n "$NS_FILTER" ]; then
|
||||
DRIFT_HITS="$(grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' "$DRIFT_AGG_JSON" 2>/dev/null | sed 's/.*: *"//;s/"//' | grep -E "/etc/orca/actual/${NS_REGEX}/" || true)"
|
||||
else
|
||||
DRIFT_HITS="$(grep -o '"drift_confirmed"[[:space:]]*:[[:space:]]*true' "$DRIFT_AGG_JSON" 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$DRIFT_HITS" ]; then
|
||||
orca_log_error "orca-pull" "$TXN_DIR" "drift-detected" "namespace=${NAMESPACE:-cluster-wide}"
|
||||
|
||||
Reference in New Issue
Block a user