From 59b40fe2441e7b74693b3c242f06104d177333b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Mon, 12 Jan 2026 23:00:10 -0800 Subject: [PATCH 01/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/bootstrap/cmd/main.go | 3 +- app/bootstrap/internal/lifecycle/lifecycle.go | 4 +- app/bootstrap/internal/net/broadcast.go | 109 ---- app/bootstrap/internal/net/dispatch.go | 123 +++++ app/bootstrap/internal/state/global.go | 1 + app/keeper/cmd/main.go | 2 + app/keeper/internal/route/store/contribute.go | 1 - app/nexus/cmd/main.go | 1 + .../initialization/recovery/keeper.go | 26 +- .../initialization/recovery/keeper_test.go | 8 +- .../initialization/recovery/recovery.go | 4 +- .../initialization/recovery/recovery_test.go | 28 +- .../initialization/recovery/root_key.go | 142 ------ .../initialization/recovery/root_key_test.go | 469 ------------------ .../internal/initialization/recovery/url.go | 40 -- go.mod | 2 +- go.sum | 6 +- jira.xml | 17 + 18 files changed, 180 insertions(+), 806 deletions(-) create mode 100644 app/bootstrap/internal/net/dispatch.go delete mode 100644 app/nexus/internal/initialization/recovery/root_key.go delete mode 100644 app/nexus/internal/initialization/recovery/root_key_test.go delete mode 100644 app/nexus/internal/initialization/recovery/url.go diff --git a/app/bootstrap/cmd/main.go b/app/bootstrap/cmd/main.go index 1b16ad0c..2903abcb 100644 --- a/app/bootstrap/cmd/main.go +++ b/app/bootstrap/cmd/main.go @@ -2,6 +2,7 @@ // \\\\\ Copyright 2024-present SPIKE contributors. // \\\\\\\ SPDX-License-Identifier: Apache-2.0 +// package main is the main entry point of SPIKE Bootstrap. package main import ( @@ -15,10 +16,10 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/log" "github.com/spiffe/spike-sdk-go/security/mem" - "github.com/spiffe/spike/app/bootstrap/internal/state" "github.com/spiffe/spike/app/bootstrap/internal/lifecycle" "github.com/spiffe/spike/app/bootstrap/internal/net" + "github.com/spiffe/spike/app/bootstrap/internal/state" "github.com/spiffe/spike/internal/config" ) diff --git a/app/bootstrap/internal/lifecycle/lifecycle.go b/app/bootstrap/internal/lifecycle/lifecycle.go index 264b3dea..e1466881 100644 --- a/app/bootstrap/internal/lifecycle/lifecycle.go +++ b/app/bootstrap/internal/lifecycle/lifecycle.go @@ -107,7 +107,9 @@ func ShouldBootstrap() bool { namespace := "spike" // Read namespace from the service account if not specified - if nsBytes, readErr := os.ReadFile(k8sServiceAccountNamespace); readErr == nil { + if nsBytes, readErr := os.ReadFile( + k8sServiceAccountNamespace, + ); readErr == nil { namespace = string(nsBytes) } diff --git a/app/bootstrap/internal/net/broadcast.go b/app/bootstrap/internal/net/broadcast.go index 448de056..95f0f299 100644 --- a/app/bootstrap/internal/net/broadcast.go +++ b/app/bootstrap/internal/net/broadcast.go @@ -12,9 +12,7 @@ import ( "encoding/hex" "fmt" "io" - "time" - "github.com/cloudflare/circl/secretsharing" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" "github.com/spiffe/spike-sdk-go/config/env" @@ -48,7 +46,6 @@ import ( // Parameters: // - ctx: Context for cancellation and timeout control // - api: SPIKE API client for communicating with keepers - func BroadcastKeepers(ctx context.Context, api *spike.API) { const fName = "BroadcastKeepers" @@ -97,61 +94,6 @@ func BroadcastKeepers(ctx context.Context, api *spike.API) { } } -// broadcastToKeeper sends a root key share to a single SPIKE Keeper instance. -// It creates a timeout context for the operation and retries with exponential -// backoff until success or the maximum retry attempts are exhausted. -// -// Parameters: -// - ctx: Parent context for cancellation -// - api: SPIKE API client for communicating with keepers -// - rs: Root key shares generated by Shamir secret sharing -// - keeperID: Identifier of the target keeper -// - keeperURL: URL of the target keeper -// - timeout: Timeout duration for the operation -// - maxRetries: Maximum number of retry attempts -// -// Returns: -// - *sdkErrors.SDKError if the keeper cannot be reached, nil on success -func broadcastToKeeper( - ctx context.Context, api *spike.API, - rs []secretsharing.Share, keeperID, keeperURL string, - timeout time.Duration, maxRetries int, -) *sdkErrors.SDKError { - const fName = "broadcastToKeeper" - - keeperShare := state.KeeperShare(rs, keeperID) - - keeperCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - _, err := retry.WithMaxAttempts(keeperCtx, maxRetries, - func() (bool, *sdkErrors.SDKError) { - log.Info(fName, - "message", "sending shard to keeper", - "keeper_id", keeperID, - "keeper_url", keeperURL, - ) - - contributeErr := contributeWithContext( - keeperCtx, api, keeperShare, keeperID, - ) - if contributeErr != nil { - warnErr := sdkErrors.ErrAPIPostFailed.Wrap(contributeErr) - warnErr.Msg = "failed to send shard: will retry" - log.WarnErr(fName, *warnErr) - return false, warnErr - } - return true, nil - }, - retry.WithBackOffOptions( - retry.WithInitialInterval(env.BootstrapKeeperRetryInitialIntervalVal()), - retry.WithMaxInterval(env.BootstrapKeeperRetryMaxIntervalVal()), - ), - ) - - return err -} - // VerifyInitialization confirms that the SPIKE Nexus initialization was // successful by performing an end-to-end encryption test. The function // generates a random 32-byte value, encrypts it using AES-GCM with the root @@ -264,54 +206,3 @@ func AcquireSource() *workloadapi.X509Source { return src } - -// contributeWithContext wraps api.Contribute so cancellation/timeouts can be -// enforced. The call is executed in a goroutine and the function waits for -// either the contribution result or ctx.Done, returning ctx.Err() when the -// context ends first. -// -// TODO: fix the API layer as soon as possible. -// -// IMPORTANT: This is a workaround, not a proper fix. When the context times -// out, this function returns early but the underlying api.Contribute call -// continues running in the background. This means: -// - The HTTP request is NOT actually cancelled -// - On retries, multiple concurrent requests may be in flight to the same -// keeper -// - Resources (goroutines, connections) are leaked until the orphaned calls -// complete -// -// The proper fix is to update the SDK so that api.Contribute accepts a -// context.Context parameter and uses http.NewRequestWithContext internally. -// This would allow the HTTP client to respect cancellation and timeouts. -// -// Parameters: -// - ctx: Context that controls cancellation/deadline for the contribution -// - api: SPIKE API client used to send the share -// - share: Keeper share being contributed -// - keeperID: Identifier of the target keeper -func contributeWithContext( - ctx context.Context, api *spike.API, - share secretsharing.Share, keeperID string, -) error { - done := make(chan error, 1) - - go func() { - contributeErr := api.Contribute(share, keeperID) - // Explicitly send nil to avoid the nil-interface-with-nil-pointer issue. - // If we send a nil *SDKError directly, it becomes a non-nil error interface - // holding a nil pointer, causing "err != nil" checks to incorrectly pass. - if contributeErr == nil { - done <- nil - return - } - done <- contributeErr - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} diff --git a/app/bootstrap/internal/net/dispatch.go b/app/bootstrap/internal/net/dispatch.go new file mode 100644 index 00000000..116e9a9d --- /dev/null +++ b/app/bootstrap/internal/net/dispatch.go @@ -0,0 +1,123 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +package net + +import ( + "context" + "time" + + "github.com/cloudflare/circl/secretsharing" + spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/config/env" + sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/log" + "github.com/spiffe/spike-sdk-go/retry" + + "github.com/spiffe/spike/app/bootstrap/internal/state" +) + +// contributeWithContext wraps api.Contribute so cancellation/timeouts can be +// enforced. The call is executed in a goroutine, and the function waits for +// either the contribution result or ctx.Done, returning ctx.Err() when the +// context ends first. +// +// IMPORTANT: This is a workaround, not a proper fix. When the context times +// out, this function returns early, but the underlying api.Contribute call +// continues running in the background. This means: +// - The HTTP request is NOT actually canceled +// - On retries, multiple concurrent requests may be in flight to the same +// keeper +// - Resources (goroutines, connections) are leaked until the orphaned calls +// complete +// +// The proper fix is to update the SDK so that api.Contribute accepts a +// context.Context parameter and uses http.NewRequestWithContext internally. +// This would allow the HTTP client to respect cancellation and timeouts. +// +// Parameters: +// - ctx: Context that controls cancellation/deadline for the contribution +// - api: SPIKE API client used to send the share +// - share: Keeper share being contributed +// - keeperID: Identifier of the target keeper +func contributeWithContext( + ctx context.Context, api *spike.API, + share secretsharing.Share, keeperID string, +) error { + done := make(chan error, 1) + + go func() { + contributeErr := api.Contribute(share, keeperID) + // Explicitly send nil to avoid the nil-interface-with-nil-pointer issue. + // If we send a nil *SDKError directly, it becomes a non-nil error interface + // holding a nil pointer, causing "err != nil" checks to incorrectly pass. + if contributeErr == nil { + done <- nil + return + } + done <- contributeErr + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// broadcastToKeeper sends a root key share to a single SPIKE Keeper instance. +// It creates a timeout context for the operation and retries with exponential +// backoff until success or the maximum retry attempts are exhausted. +// +// Parameters: +// - ctx: Parent context for cancellation +// - api: SPIKE API client for communicating with keepers +// - rs: Root key shares generated by Shamir secret sharing +// - keeperID: Identifier of the target keeper +// - keeperURL: URL of the target keeper +// - timeout: Timeout duration for the operation +// - maxRetries: Maximum number of retry attempts +// +// Returns: +// - *sdkErrors.SDKError if the keeper cannot be reached, nil on success +func broadcastToKeeper( + ctx context.Context, api *spike.API, + rs []secretsharing.Share, keeperID, keeperURL string, + timeout time.Duration, maxRetries int, +) *sdkErrors.SDKError { + const fName = "broadcastToKeeper" + + keeperShare := state.KeeperShare(rs, keeperID) + + keeperCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + _, err := retry.WithMaxAttempts(keeperCtx, maxRetries, + func() (bool, *sdkErrors.SDKError) { + log.Info(fName, + "message", "sending shard to keeper", + "keeper_id", keeperID, + "keeper_url", keeperURL, + ) + + contributeErr := contributeWithContext( + keeperCtx, api, keeperShare, keeperID, + ) + if contributeErr != nil { + warnErr := sdkErrors.ErrAPIPostFailed.Wrap(contributeErr) + warnErr.Msg = "failed to send shard: will retry" + log.WarnErr(fName, *warnErr) + return false, warnErr + } + return true, nil + }, + retry.WithBackOffOptions( + retry.WithInitialInterval(env.BootstrapKeeperRetryInitialIntervalVal()), + retry.WithMaxInterval(env.BootstrapKeeperRetryMaxIntervalVal()), + ), + ) + + return err +} diff --git a/app/bootstrap/internal/state/global.go b/app/bootstrap/internal/state/global.go index 61ad971b..16ea6c8d 100644 --- a/app/bootstrap/internal/state/global.go +++ b/app/bootstrap/internal/state/global.go @@ -13,6 +13,7 @@ import ( var ( // rootKeySeed stores the root key seed generated during initialization. // It is kept in memory to allow encryption operations during bootstrap. + // It is cleared immediately after a successful bootstrap. rootKeySeed [crypto.AES256KeySize]byte // rootKeySeedMu provides mutual exclusion for access to the root key seed. rootKeySeedMu sync.RWMutex diff --git a/app/keeper/cmd/main.go b/app/keeper/cmd/main.go index 12cb60e0..e0d24efd 100644 --- a/app/keeper/cmd/main.go +++ b/app/keeper/cmd/main.go @@ -2,6 +2,7 @@ // \\\\\ Copyright 2024-present SPIKE contributors. // \\\\\\\ SPDX-License-Identifier: Apache-2.0 +// package main is the main entry point for SPIKE Keeper. package main import ( @@ -14,6 +15,7 @@ import ( "github.com/spiffe/spike-sdk-go/predicate" "github.com/spiffe/spike-sdk-go/spiffe" "github.com/spiffe/spike-sdk-go/spiffeid" + http "github.com/spiffe/spike/app/keeper/internal/route/base" "github.com/spiffe/spike/internal/config" "github.com/spiffe/spike/internal/out" diff --git a/app/keeper/internal/route/store/contribute.go b/app/keeper/internal/route/store/contribute.go index 95148f5e..7be66529 100644 --- a/app/keeper/internal/route/store/contribute.go +++ b/app/keeper/internal/route/store/contribute.go @@ -20,7 +20,6 @@ import ( // system. It processes incoming shard data and stores it in the system state. // // Security: -// // This endpoint validates that the peer is either SPIKE Bootstrap or SPIKE // Nexus using SPIFFE ID verification. SPIKE Bootstrap contributes shards // during initial system setup, while SPIKE Nexus contributes shards during diff --git a/app/nexus/cmd/main.go b/app/nexus/cmd/main.go index 00d04d9b..7fbd92dd 100644 --- a/app/nexus/cmd/main.go +++ b/app/nexus/cmd/main.go @@ -2,6 +2,7 @@ // \\\\\ Copyright 2024-present SPIKE contributors. // \\\\\\\ SPDX-License-Identifier: Apache-2.0 +// package main is the main entry point for SPIKE Nexus. package main import ( diff --git a/app/nexus/internal/initialization/recovery/keeper.go b/app/nexus/internal/initialization/recovery/keeper.go index 3c6c94da..3cfe7121 100644 --- a/app/nexus/internal/initialization/recovery/keeper.go +++ b/app/nexus/internal/initialization/recovery/keeper.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/spiffe/go-spiffe/v2/workloadapi" + "github.com/spiffe/spike-sdk-go/api/url" "github.com/spiffe/spike-sdk-go/config/env" "github.com/spiffe/spike-sdk-go/crypto" sdkErrors "github.com/spiffe/spike-sdk-go/errors" @@ -84,26 +85,11 @@ func iterateKeepersAndInitializeState( "id", keeperID, "url", keeperAPIRoot, ) - // Configuration errors (malformed keeper URLs) are logged but not fatal. - // Rationale: - // 1. Availability: If threshold=3 and we have 4 valid + 2 invalid URLs, - // recovery can still succeed with the valid keepers. - // 2. Graceful degradation: The system becomes operational despite partial - // misconfiguration; operators can fix the env var and restart later. - // 3. Consistency: Similar to the network errors or unmarshal failures below, - // a bad URL means this keeper is unavailable, not a fatal condition. - // 4. The Shamir threshold mechanism already protects against insufficient - // shards. - u, urlErr := shardURL(keeperAPIRoot) - if urlErr != nil { - log.WarnErr(fName, *urlErr) - continue - } - + u := url.ShardFromKeperAPIRoot(keeperAPIRoot) data, err := shardGetResponse(source, u) if err != nil { warnErr := sdkErrors.ErrNetPeerConnection.Wrap(err) - warnErr.Msg = "failed to get shard from keeper" + warnErr.Msg = "failed to get shard from keeper: " + u log.WarnErr(fName, *warnErr) // just log: will retry continue } @@ -143,7 +129,7 @@ func iterateKeepersAndInitializeState( // `InitializeBackingStoreFromKeepers()` resets `successfulKeeperShards` // which points to the same shards here. And until recovery, we will keep // a threshold number of shards in memory. - ss := make([]ShamirShard, 0) + ss := make([]crypto.ShamirShard, 0) for ix, shard := range successfulKeeperShards { id, err := strconv.Atoi(ix) if err != nil { @@ -164,13 +150,13 @@ func iterateKeepersAndInitializeState( return false } - ss = append(ss, ShamirShard{ + ss = append(ss, crypto.ShamirShard{ ID: uint64(id), Value: shard, }) } - rk := ComputeRootKeyFromShards(ss) + rk := crypto.ComputeRootKeyFromShards(ss) // Security: Crash if there is a problem with root key recovery. if rk == nil || mem.Zeroed32(rk) { diff --git a/app/nexus/internal/initialization/recovery/keeper_test.go b/app/nexus/internal/initialization/recovery/keeper_test.go index fc905cd3..4e0d9517 100644 --- a/app/nexus/internal/initialization/recovery/keeper_test.go +++ b/app/nexus/internal/initialization/recovery/keeper_test.go @@ -181,7 +181,7 @@ func TestShamirShardStructure(t *testing.T) { testData[i] = byte(i) } - shard := ShamirShard{ + shard := crypto.ShamirShard{ ID: 123, Value: testData, } @@ -209,7 +209,7 @@ func TestShamirShardStructure(t *testing.T) { func TestShamirShardSliceHandling(t *testing.T) { // Test creating and manipulating slices of ShamirShard (as done in the function) - shards := make([]ShamirShard, 0) + shards := make([]crypto.ShamirShard, 0) // Add some test shards for i := 0; i < 3; i++ { @@ -218,7 +218,7 @@ func TestShamirShardSliceHandling(t *testing.T) { testData[j] = byte((i + j) % 256) } - shard := ShamirShard{ + shard := crypto.ShamirShard{ ID: uint64(i + 1), Value: testData, } @@ -250,7 +250,7 @@ func TestShamirShardSliceHandling(t *testing.T) { } func TestKeeperIDConversion(t *testing.T) { - // Test the string to integer conversion logic used in the function + // Test the string-to-integer conversion logic used in the function tests := []struct { name string keeperID string diff --git a/app/nexus/internal/initialization/recovery/recovery.go b/app/nexus/internal/initialization/recovery/recovery.go index 384d3be6..e9cd1e27 100644 --- a/app/nexus/internal/initialization/recovery/recovery.go +++ b/app/nexus/internal/initialization/recovery/recovery.go @@ -125,7 +125,7 @@ func InitializeBackingStoreFromKeepers(source *workloadapi.X509Source) { // It will return early with an error log if: // - There are not enough shards to meet the threshold // - The SPIFFE source cannot be created -func RestoreBackingStoreFromPilotShards(shards []ShamirShard) { +func RestoreBackingStoreFromPilotShards(shards []crypto.ShamirShard) { const fName = "RestoreBackingStoreFromPilotShards" log.Info( @@ -163,7 +163,7 @@ func RestoreBackingStoreFromPilotShards(shards []ShamirShard) { ) // Recover the root key using the threshold number of shards - rk := ComputeRootKeyFromShards(shards) + rk := crypto.ComputeRootKeyFromShards(shards) if rk == nil || mem.Zeroed32(rk) { failErr := *sdkErrors.ErrShamirReconstructionFailed.Clone() failErr.Msg = "failed to recover the root key" diff --git a/app/nexus/internal/initialization/recovery/recovery_test.go b/app/nexus/internal/initialization/recovery/recovery_test.go index c5301400..9a809fed 100644 --- a/app/nexus/internal/initialization/recovery/recovery_test.go +++ b/app/nexus/internal/initialization/recovery/recovery_test.go @@ -40,7 +40,7 @@ func TestRestoreBackingStoreFromPilotShards_InsufficientShards(t *testing.T) { t.Setenv(env.NexusShamirThreshold, "3") // Create insufficient shards (only 2, but the threshold is 3) - shards := make([]ShamirShard, 2) + shards := make([]crypto.ShamirShard, 2) for i := range shards { testData := &[crypto.AES256KeySize]byte{} for j := range testData { @@ -48,7 +48,7 @@ func TestRestoreBackingStoreFromPilotShards_InsufficientShards(t *testing.T) { } testData[0] = byte(i + 1) // Ensure non-zero - shards[i] = ShamirShard{ + shards[i] = crypto.ShamirShard{ ID: uint64(i + 1), Value: testData, } @@ -74,12 +74,12 @@ func TestRestoreBackingStoreFromPilotShards_InvalidShards(t *testing.T) { tests := []struct { name string - setupShard func() ShamirShard + setupShard func() crypto.ShamirShard }{ { name: "nil value shard", - setupShard: func() ShamirShard { - return ShamirShard{ + setupShard: func() crypto.ShamirShard { + return crypto.ShamirShard{ ID: 1, Value: nil, } @@ -87,10 +87,10 @@ func TestRestoreBackingStoreFromPilotShards_InvalidShards(t *testing.T) { }, { name: "zero ID shard", - setupShard: func() ShamirShard { + setupShard: func() crypto.ShamirShard { testData := &[crypto.AES256KeySize]byte{} testData[0] = 1 // Non-zero data - return ShamirShard{ + return crypto.ShamirShard{ ID: 0, // Zero ID Value: testData, } @@ -98,9 +98,9 @@ func TestRestoreBackingStoreFromPilotShards_InvalidShards(t *testing.T) { }, { name: "zeroed value shard", - setupShard: func() ShamirShard { + setupShard: func() crypto.ShamirShard { testData := &[crypto.AES256KeySize]byte{} // All zeros - return ShamirShard{ + return crypto.ShamirShard{ ID: 1, Value: testData, } @@ -110,7 +110,7 @@ func TestRestoreBackingStoreFromPilotShards_InvalidShards(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - shards := []ShamirShard{tt.setupShard()} + shards := []crypto.ShamirShard{tt.setupShard()} defer func() { if r := recover(); r == nil { @@ -194,7 +194,7 @@ func TestShamirShardValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - shard := ShamirShard{ + shard := crypto.ShamirShard{ ID: tt.id, Value: tt.value, } @@ -228,7 +228,7 @@ func TestShamirShardValidation(t *testing.T) { func TestShamirShardSliceOperations(t *testing.T) { // Test operations on slices of ShamirShard - shards := make([]ShamirShard, 3) + shards := make([]crypto.ShamirShard, 3) // Initialize test shards for i := range shards { @@ -238,7 +238,7 @@ func TestShamirShardSliceOperations(t *testing.T) { } testData[0] = byte(i + 1) // Ensure non-zero - shards[i] = ShamirShard{ + shards[i] = crypto.ShamirShard{ ID: uint64(i + 1), Value: testData, } @@ -372,7 +372,7 @@ func TestShardDataIntegrity(t *testing.T) { } // Create shard - shard := ShamirShard{ + shard := crypto.ShamirShard{ ID: 123, Value: &originalData, } diff --git a/app/nexus/internal/initialization/recovery/root_key.go b/app/nexus/internal/initialization/recovery/root_key.go deleted file mode 100644 index e35d67b8..00000000 --- a/app/nexus/internal/initialization/recovery/root_key.go +++ /dev/null @@ -1,142 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package recovery - -import ( - "github.com/cloudflare/circl/group" - "github.com/cloudflare/circl/secretsharing" - "github.com/spiffe/spike-sdk-go/config/env" - "github.com/spiffe/spike-sdk-go/crypto" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/security/mem" -) - -type ShamirShard struct { - ID uint64 - Value *[crypto.AES256KeySize]byte -} - -// ComputeRootKeyFromShards reconstructs the original root key from a slice of -// ShamirShard. It uses Shamir's Secret Sharing scheme to recover the original -// secret. -// -// Parameters: -// - ss []ShamirShard: A slice of ShamirShard structures, each containing -// an ID and a pointer to a 32-byte value representing a secret share -// -// Returns: -// - *[32]byte: A pointer to the reconstructed 32-byte root key -// -// The function will: -// - Convert each ShamirShard into a properly formatted secretsharing.Share -// - Use the IDs from the provided ShamirShards -// - Retrieve the threshold from the environment -// - Reconstruct the original secret using the secretsharing.Recover function -// - Validate the recovered key has the correct length (32 bytes) -// - Zero out all shares after use for security -// -// It will log a fatal error and exit if: -// - Any share fails to unmarshal properly -// - The recovery process fails -// - The reconstructed key is nil -// - The binary representation has an incorrect length -func ComputeRootKeyFromShards(ss []ShamirShard) *[crypto.AES256KeySize]byte { - const fName = "ComputeRootKeyFromShards" - - g := group.P256 - shares := make([]secretsharing.Share, 0, len(ss)) - // Security: Ensure that the shares are zeroed out after the function returns: - defer func() { - for _, s := range shares { - s.ID.SetUint64(0) - s.Value.SetUint64(0) - } - }() - - // Process all provided shares - for _, shamirShard := range ss { - // Create a new share with sequential ID (starting from 1): - share := secretsharing.Share{ - ID: g.NewScalar(), - Value: g.NewScalar(), - } - - // Set ID - share.ID.SetUint64(shamirShard.ID) - - // Unmarshal the binary data - unmarshalErr := share.Value.UnmarshalBinary(shamirShard.Value[:]) - if unmarshalErr != nil { - failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(unmarshalErr) - failErr.Msg = "failed to unmarshal shard" - log.FatalErr(fName, *failErr) - } - - shares = append(shares, share) - } - - // Recover the secret - // The first parameter to Recover is threshold-1 - // We need the threshold from the environment - threshold := env.ShamirThresholdVal() - reconstructed, recoverErr := secretsharing.Recover(uint(threshold-1), shares) - if recoverErr != nil { - // Security: Reset shares. - // Defer won't get called because log.FatalErr terminates the program. - for _, s := range shares { - s.ID.SetUint64(0) - s.Value.SetUint64(0) - } - - failErr := sdkErrors.ErrShamirReconstructionFailed.Wrap(recoverErr) - failErr.Msg = "failed to recover secret" - log.FatalErr(fName, *failErr) - } - - if reconstructed == nil { - // Security: Reset shares. - // Defer won't get called because log.FatalErr terminates the program. - for _, s := range shares { - s.ID.SetUint64(0) - s.Value.SetUint64(0) - } - - failErr := *sdkErrors.ErrShamirReconstructionFailed.Clone() - failErr.Msg = "failed to reconstruct the root key" - log.FatalErr(fName, failErr) - } - - if reconstructed != nil { - binaryRec, marshalErr := reconstructed.MarshalBinary() - if marshalErr != nil { - // Security: Zero out: - reconstructed.SetUint64(0) - - failErr := sdkErrors.ErrDataMarshalFailure.Wrap(marshalErr) - failErr.Msg = "failed to marshal reconstructed key" - log.FatalErr(fName, *failErr) - - return &[crypto.AES256KeySize]byte{} - } - - if len(binaryRec) != crypto.AES256KeySize { - failErr := *sdkErrors.ErrDataInvalidInput.Clone() - failErr.Msg = "reconstructed root key has incorrect length" - log.FatalErr(fName, failErr) - - return &[crypto.AES256KeySize]byte{} - } - - var result [crypto.AES256KeySize]byte - copy(result[:], binaryRec) - // Security: Zero out temporary variables before the function exits. - mem.ClearBytes(binaryRec) - - return &result - } - - return &[crypto.AES256KeySize]byte{} -} diff --git a/app/nexus/internal/initialization/recovery/root_key_test.go b/app/nexus/internal/initialization/recovery/root_key_test.go deleted file mode 100644 index 93fa284b..00000000 --- a/app/nexus/internal/initialization/recovery/root_key_test.go +++ /dev/null @@ -1,469 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package recovery - -import ( - "os" - "testing" - - "github.com/cloudflare/circl/group" - appEnv "github.com/spiffe/spike-sdk-go/config/env" - "github.com/spiffe/spike-sdk-go/crypto" -) - -func TestShamirShardStruct(t *testing.T) { - // Test creating and manipulating ShamirShard structures - testData := &[crypto.AES256KeySize]byte{} - for i := range testData { - testData[i] = byte(i % 256) - } - - shard := ShamirShard{ - ID: 42, - Value: testData, - } - - // Test ID - if shard.ID != 42 { - t.Errorf("Expected ID 42, got %d", shard.ID) - } - - // Test Value pointer - if shard.Value == nil { - t.Fatal("Shard value should not be nil") - } - - // Test Value length - // noinspection GoBoolExpressions - if len(shard.Value) != crypto.AES256KeySize { - t.Errorf("Expected value length %d, got %d", - crypto.AES256KeySize, len(shard.Value)) - } - - // Test data integrity - for i, b := range shard.Value { - expected := byte(i % 256) - if b != expected { - t.Errorf("Data mismatch at index %d: expected %d, got %d", i, expected, b) - } - } - - // Test that it's actually a pointer (modifying shard affects the original) - originalByte := testData[0] - shard.Value[0] = 255 - if testData[0] != 255 { - t.Error("Expected modification through pointer to affect original") - } - testData[0] = originalByte // Restore -} - -func TestShamirShardZeroValues(t *testing.T) { - // Test zero-value ShamirShard - var zeroShard ShamirShard - - if zeroShard.ID != 0 { - t.Errorf("Zero-value shard should have ID 0, got %d", zeroShard.ID) - } - - if zeroShard.Value != nil { - t.Error("Zero-value shard should have nil Value") - } -} - -func TestShamirShardSliceOperationsRootKey(t *testing.T) { - // Test creating and operating on slices of ShamirShard - numShards := 3 - shards := make([]ShamirShard, numShards) - - // Initialize shards - for i := range shards { - testData := &[crypto.AES256KeySize]byte{} - for j := range testData { - testData[j] = byte((i*10 + j) % 256) - } - - shards[i] = ShamirShard{ - ID: uint64(i + 1), - Value: testData, - } - } - - // Test slice length - if len(shards) != numShards { - t.Errorf("Expected %d shards, got %d", numShards, len(shards)) - } - - // Test each shard - for i, shard := range shards { - expectedID := uint64(i + 1) - if shard.ID != expectedID { - t.Errorf("Shard %d: expected ID %d, got %d", i, expectedID, shard.ID) - } - - if shard.Value == nil { - t.Errorf("Shard %d: value should not be nil", i) - } - - // Test unique data per shard - expectedFirstByte := byte(i * 10) - if shard.Value[0] != expectedFirstByte { - t.Errorf("Shard %d: expected first byte %d, got %d", - i, expectedFirstByte, shard.Value[0]) - } - } -} - -func TestComputeRootKeyFromShards_InvalidInput(t *testing.T) { - // Save the original environment - originalThreshold := os.Getenv(appEnv.NexusShamirThreshold) - defer func() { - if originalThreshold != "" { - _ = os.Setenv(appEnv.NexusShamirThreshold, originalThreshold) - } else { - _ = os.Unsetenv(appEnv.NexusShamirThreshold) - } - }() - - // Set a valid threshold - _ = os.Setenv("SPIKE_NEXUS_SHAMIR_THRESHOLD", "2") - - tests := []struct { - name string - setupShards func() []ShamirShard - shouldExit bool - }{ - { - name: "empty shards slice", - setupShards: func() []ShamirShard { - return []ShamirShard{} - }, - shouldExit: true, // Will call log.FatalLn during recovery - }, - { - name: "single shard insufficient for threshold", - setupShards: func() []ShamirShard { - testData := &[crypto.AES256KeySize]byte{} - testData[0] = 1 - return []ShamirShard{ - {ID: 1, Value: testData}, - } - }, - shouldExit: true, // Insufficient for a threshold of 2 - }, - { - name: "shard with nil value", - setupShards: func() []ShamirShard { - return []ShamirShard{ - {ID: 1, Value: nil}, - } - }, - shouldExit: true, // Will call log.FatalLn when trying to access nil slice - }, - { - name: "shard with zero ID", - setupShards: func() []ShamirShard { - testData1 := &[crypto.AES256KeySize]byte{} - testData1[0] = 1 - testData2 := &[crypto.AES256KeySize]byte{} - testData2[0] = 2 - return []ShamirShard{ - {ID: 0, Value: testData1}, // Zero ID - {ID: 1, Value: testData2}, // Valid ID - } - }, - shouldExit: true, // Will likely fail during Shamir reconstruction with zero ID - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - shards := tt.setupShards() - - if tt.shouldExit { - // ComputeRootKeyFromShards calls log.FatalLn which calls os.Exit() - // We skip these tests since they would terminate the test runner - t.Skip("Skipping test that would call os.Exit() - function calls log.FatalLn") - return - } - - result := ComputeRootKeyFromShards(shards) - - // If we get here, check the result - if result == nil { - t.Error("Expected non-nil result") - } - // noinspection GoBoolExpressions - if len(result) != crypto.AES256KeySize { - t.Errorf("Expected result length %d, got %d", - crypto.AES256KeySize, len(result)) - } - }) - } -} - -func TestComputeRootKeyFromShards_ValidInput(t *testing.T) { - // Save the original environment - originalThreshold := os.Getenv(appEnv.NexusShamirThreshold) - defer func() { - if originalThreshold != "" { - _ = os.Setenv(appEnv.NexusShamirThreshold, originalThreshold) - } else { - _ = os.Unsetenv(appEnv.NexusShamirThreshold) - } - }() - - // Set the threshold to 2 - _ = os.Setenv("SPIKE_NEXUS_SHAMIR_THRESHOLD", "2") - - // This test will likely fail because we need actual valid Shamir shares - // that were generated from the same secret. Here we test the structure - // and basic validation. - - // Create test shards (note: these won't be valid Shamir shares) - testData1 := &[crypto.AES256KeySize]byte{} - testData2 := &[crypto.AES256KeySize]byte{} - - // Fill with some test data - for i := range testData1 { - testData1[i] = byte(i % 256) - testData2[i] = byte((i + 100) % 256) - } - - shards := []ShamirShard{ - {ID: 1, Value: testData1}, - {ID: 2, Value: testData2}, - } - - // This will likely panic due to invalid Shamir reconstruction, - // but we test that it gets to the reconstruction phase - defer func() { - if r := recover(); r != nil { - t.Log("Function panicked as expected due to invalid test data:", r) - } - }() - - result := ComputeRootKeyFromShards(shards) - - // If we get here without `panic`, validate `result` - if result == nil { - t.Error("Expected non-nil result") - } - // noinspection GoBoolExpressions - if len(result) != crypto.AES256KeySize { - t.Errorf("Expected result length %d, got %d", - crypto.AES256KeySize, len(result)) - } -} - -func TestShamirShardDataTypes(t *testing.T) { - // Test that ShamirShard uses correct data types - var shard ShamirShard - - // Test ID type - shard.ID = uint64(18446744073709551615) // Max uint64 - if shard.ID != 18446744073709551615 { - t.Error("ID should support full uint64 range") - } - - // Test Value type - testData := &[crypto.AES256KeySize]byte{} - shard.Value = testData - if shard.Value != testData { - t.Error("Value should be a pointer to [32]byte array") - } - - // Test Value array size - // noinspection GoBoolExpressions - if len(shard.Value) != 32 { - t.Errorf("Value array should be 32 bytes, got %d", len(shard.Value)) - } - - // Test crypto constant - // noinspection GoBoolExpressions - if crypto.AES256KeySize != 32 { - t.Errorf("Expected AES256KeySize to be 32, got %d", crypto.AES256KeySize) - } -} - -func TestShamirShardComparison(t *testing.T) { - // Test comparing ShamirShard structures - testData1 := &[crypto.AES256KeySize]byte{} - testData2 := &[crypto.AES256KeySize]byte{} - testData1[0] = 1 - testData2[0] = 1 // Same content, different pointer - - shard1 := ShamirShard{ID: 1, Value: testData1} - shard2 := ShamirShard{ID: 1, Value: testData2} - shard3 := ShamirShard{ID: 2, Value: testData1} - - // Test ID comparison - if shard1.ID != shard2.ID { - t.Error("Shards with same ID should have equal IDs") - } - if shard1.ID == shard3.ID { - t.Error("Shards with different IDs should not have equal IDs") - } - - // Test pointer comparison (different pointers even with the same content) - if shard1.Value == shard2.Value { - t.Error("Different pointers should not be equal") - } - if shard1.Value != shard3.Value { - t.Error("Same pointer should be equal") - } - - // Test content comparison - if shard1.Value[0] != shard2.Value[0] { - t.Error("Same content should be equal") - } -} - -func TestGroupP256Operations(t *testing.T) { - // Test operations with `group.P256` (as used in ComputeRootKeyFromShards) - g := group.P256 - - // Test creating scalars - scalar1 := g.NewScalar() - scalar2 := g.NewScalar() - - if scalar1 == nil { - t.Error("NewScalar should not return nil") - } - if scalar2 == nil { - t.Error("NewScalar should not return nil") - } - - // Test setting values - if scalar1 != nil { - scalar1.SetUint64(123) - } - if scalar2 != nil { - scalar2.SetUint64(456) - } - - // Test that they can be marshaled/unmarshaled - if scalar1 != nil { - data1, err := scalar1.MarshalBinary() - if err != nil { - t.Errorf("MarshalBinary failed: %v", err) - } - - scalar3 := g.NewScalar() - err = scalar3.UnmarshalBinary(data1) - if err != nil { - t.Errorf("UnmarshalBinary failed: %v", err) - } - - // Test that unmarshaled scalar equals to the original - if !scalar1.IsEqual(scalar3) { - t.Error("Unmarshaled scalar should equal original") - } - } -} - -func TestArrayOperations(t *testing.T) { - // Test operations on [32]byte arrays as used in ShamirShard - - // Test array creation - var arr1 [crypto.AES256KeySize]byte - arr2 := [crypto.AES256KeySize]byte{} - - // noinspection GoBoolExpressions - if len(arr1) != crypto.AES256KeySize { - t.Errorf("Array length should be %d, got %d", crypto.AES256KeySize, len(arr1)) - } - // noinspection GoBoolExpressions - if len(arr2) != crypto.AES256KeySize { - t.Errorf("Array length should be %d, got %d", crypto.AES256KeySize, len(arr2)) - } - - // Test array assignment - for i := range arr1 { - arr1[i] = byte(i % 256) - } - - // Test copy operation (as used in ComputeRootKeyFromShards) - copy(arr2[:], arr1[:]) - - for i, b := range arr2 { - if b != arr1[i] { - t.Errorf("Copy failed at index %d: expected %d, got %d", i, arr1[i], b) - } - } - - // Test pointer to array - ptr := &arr1 - // noinspection GoBoolExpressions - if len(ptr) != crypto.AES256KeySize { - t.Errorf("Pointer array length should be %d, got %d", crypto.AES256KeySize, len(ptr)) - } - - // Test modification through a pointer - ptr[0] = 255 - if arr1[0] != 255 { - t.Error("Modification through pointer should affect original array") - } -} - -func TestEnvironmentThresholdHandling(t *testing.T) { - // Test different threshold values - originalThreshold := os.Getenv(appEnv.NexusShamirThreshold) - defer func() { - if originalThreshold != "" { - _ = os.Setenv(appEnv.NexusShamirThreshold, originalThreshold) - } else { - _ = os.Unsetenv(appEnv.NexusShamirThreshold) - } - }() - - testThresholds := []string{"1", "2", "3", "5"} - - for _, threshold := range testThresholds { - t.Run("threshold_"+threshold, func(t *testing.T) { - _ = os.Setenv(appEnv.NexusShamirThreshold, threshold) - - // Test that threshold-1 calculation works - // (This is used in ComputeRootKeyFromShards) - // We can't easily test the actual function due to complexity, - // but we can verify the environment setup - - // The function would use: `threshold := env.ShamirThreshold()` - // Then: secretsharing.Recover(uint(threshold-1), shares) - - // Just verify the environment is set correctly - envValue := os.Getenv(appEnv.NexusShamirThreshold) - if envValue != threshold { - t.Errorf("Expected threshold %s, got %s", threshold, envValue) - } - }) - } -} - -func TestMemoryLayout(t *testing.T) { - // Test memory layout assumptions - var arr [crypto.AES256KeySize]byte - ptr := &arr - - // Test that pointer and array have the same underlying data - arr[10] = 42 - if ptr[10] != 42 { - t.Error("Pointer should access same memory as array") - } - - ptr[20] = 84 - if arr[20] != 84 { - t.Error("Array should reflect changes through pointer") - } - - // Test slice from an array - slice := arr[:] - slice[5] = 99 - if arr[5] != 99 { - t.Error("Array should reflect changes through slice") - } - if ptr[5] != 99 { - t.Error("Pointer should reflect changes through slice") - } -} diff --git a/app/nexus/internal/initialization/recovery/url.go b/app/nexus/internal/initialization/recovery/url.go deleted file mode 100644 index 5d649ccf..00000000 --- a/app/nexus/internal/initialization/recovery/url.go +++ /dev/null @@ -1,40 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package recovery - -import ( - "net/url" - - apiUrl "github.com/spiffe/spike-sdk-go/api/url" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" -) - -// shardURL constructs the full URL for the keeper shard endpoint by joining -// the keeper API root with the shard path. -// -// This function is used during recovery operations to build the endpoint URL -// for retrieving Shamir secret shards from SPIKE Keeper instances. -// -// Parameters: -// - keeperAPIRoot: The base URL of the keeper API -// (e.g., "https://keeper.example.com:8443") -// -// Returns: -// - string: The complete URL to the shard endpoint, or empty string on error -// - *sdkErrors.SDKError: An error if URL construction fails, nil on success -// -// Example: -// -// url, err := shardURL("https://keeper.example.com:8443") -// // Returns: "https://keeper.example.com:8443/v1/shard", nil -func shardURL(keeperAPIRoot string) (string, *sdkErrors.SDKError) { - u, err := url.JoinPath(keeperAPIRoot, string(apiUrl.KeeperShard)) - if err != nil { - failErr := sdkErrors.ErrDataInvalidInput.Wrap(err) - failErr.Msg = "failed to construct shard URL from keeper API root" - return "", failErr - } - return u, nil -} diff --git a/go.mod b/go.mod index 34677614..95f8bcbd 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.19 + github.com/spiffe/spike-sdk-go v0.17.22 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index b3e9ffed..5cc7db05 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.17.19 h1:lvCyw+bVgCBIbLy8NphG792d0HHMI7MDM1yW0c26Ao0= -github.com/spiffe/spike-sdk-go v0.17.19/go.mod h1:PZ1HlkZERxJp3PCcxRjAXJzOxul2brNumE48KG4gDE8= +github.com/spiffe/spike-sdk-go v0.17.20 h1:9Yc+elwHOl7UJPAib8oVmCve3/NDtxWY91gzw21UajI= +github.com/spiffe/spike-sdk-go v0.17.20/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.21/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.22/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/jira.xml b/jira.xml index 43bc87e6..92900988 100644 --- a/jira.xml +++ b/jira.xml @@ -17,6 +17,9 @@ potential issues before deciding which ones to formally create in GitHub. By working here first, we keep the active issue tracker focused and avoid cluttering it with early-stage or exploratory thoughts. + + Note that this is NOT a strict XML file, and parsing it as an XML will + most-likely fail. --> @@ -86,6 +89,20 @@ const GCMNonceSize = 12 to sdk. + + once https://github.com/spiffe/spike-sdk-go/pull/140/changes + is merged, the following needs to be updated accordingly + as 1. it is faking a context with timeout without actually doing + anything at the http layer + 2. it makes things extra complicate without achieving proper + network-layer cancellation: + + `spike secret list` right now lists all secrets available and requires system level access. From 8caf36330ae7bb6e0dde446be843860d624f72cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Tue, 13 Jan 2026 04:36:53 -0800 Subject: [PATCH 02/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../internal/initialization/initialization.go | 4 +- .../initialization/recovery/shard_test.go | 14 +- .../route/acl/policy/delete_intercept.go | 46 ++++--- .../route/cipher/encrypt_intercept.go | 52 ++++--- app/nexus/internal/route/cipher/handle.go | 6 +- app/nexus/internal/route/cipher/validation.go | 129 ------------------ 6 files changed, 57 insertions(+), 194 deletions(-) diff --git a/app/nexus/internal/initialization/initialization.go b/app/nexus/internal/initialization/initialization.go index 5994ff2c..0c09e193 100644 --- a/app/nexus/internal/initialization/initialization.go +++ b/app/nexus/internal/initialization/initialization.go @@ -62,9 +62,7 @@ func Initialize(source *workloadapi.X509Source) { return } - devMode := env.BackendStoreTypeVal() == env.Memory - - if devMode { + if devMode := env.BackendStoreTypeVal() == env.Memory; devMode { log.Warn( fName, "message", "in-memory mode: no SPIKE Keepers, not for production", diff --git a/app/nexus/internal/initialization/recovery/shard_test.go b/app/nexus/internal/initialization/recovery/shard_test.go index 3ba2e7d6..4b37e4c3 100644 --- a/app/nexus/internal/initialization/recovery/shard_test.go +++ b/app/nexus/internal/initialization/recovery/shard_test.go @@ -55,20 +55,13 @@ func TestShardURL_ValidInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := shardURL(tt.keeperAPIRoot) + result := apiUrl.ShardFromKeperAPIRoot(tt.keeperAPIRoot) if tt.shouldBeEmpty { if result != "" { t.Errorf("Expected empty result, got %s", result) } - if err == nil { - t.Error("Expected error for invalid input") - } } else { - if err != nil { - t.Errorf("Unexpected error: %v", err) - return - } if result == "" { t.Error("Expected non-empty result") return @@ -118,16 +111,13 @@ func TestShardURL_InvalidInput(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := shardURL(tt.keeperAPIRoot) + result := apiUrl.ShardFromKeperAPIRoot(tt.keeperAPIRoot) // Invalid inputs should return an empty string and an error if result != "" { t.Errorf("Expected empty result for invalid input, got %s", result) } - if err == nil { - t.Error("Expected error for invalid input") - } }) } } diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 80b2b635..5f2e0978 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -17,6 +17,14 @@ import ( state "github.com/spiffe/spike/app/nexus/internal/state/base" ) +func spiffeidAllowedForPolicyDelete(spiffeId string) bool { + return state.CheckAccess( + spiffeId, + cfg.PathSystemPolicyAccess, + []data.PolicyPermission{data.PermissionWrite}, + ) +} + // guardPolicyDeleteRequest validates a policy deletion request by performing // authentication, authorization, and input validation checks. // @@ -40,14 +48,26 @@ import ( func guardPolicyDeleteRequest( request reqres.PolicyDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.PolicyDeleteResponse]( - r, w, reqres.PolicyDeleteResponse{}.Unauthorized(), - ) - if alreadyResponded := err != nil; alreadyResponded { + policyID := request.ID + + // TODO: ensure this happens in ALL guard calls. + // Extract and validate SPIFFE ID before any action. + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( + r, w, reqres.PolicyDeleteResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) + if err != nil { return err } - policyID := request.ID + // TODO: ensure policy verification before other verifications on ALL guard calls. + authErr := net.RespondUnauthorizedOnPredicateFail( + spiffeidAllowedForPolicyDelete, + reqres.PolicyDeleteResponse{}.Unauthorized(), w, r, + ) + if authErr != nil { + return authErr + } validationErr := validation.ValidatePolicyID(policyID) if invalidPolicy := validationErr != nil; invalidPolicy { @@ -61,19 +81,5 @@ func guardPolicyDeleteRequest( return validationErr } - allowed := state.CheckAccess( - peerSPIFFEID.String(), cfg.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionWrite}, - ) - if !allowed { - failErr := net.Fail( - reqres.PolicyDeleteResponse{}.Unauthorized(), w, http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized.Clone() - } - - return nil + return validationErr } diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index 9109f1c2..b08c0aac 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -7,8 +7,6 @@ package cipher import ( "net/http" - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" apiAuth "github.com/spiffe/spike-sdk-go/config/auth" @@ -19,6 +17,23 @@ import ( state "github.com/spiffe/spike/app/nexus/internal/state/base" ) +func spiffeidAllowedForEncryptCipher(spiffeid string) bool { + // Lite Workloads are always allowed: + allowed := false + if sdkSpiffeid.IsLiteWorkload(spiffeid) { + allowed = true + } + // If not, do a policy check to determine if the request is allowed: + if !allowed { + allowed = state.CheckAccess( + spiffeid, + apiAuth.PathSystemCipherEncrypt, + []data.PolicyPermission{data.PermissionExecute}, + ) + } + return allowed +} + // guardEncryptCipherRequest validates a cipher encryption request by // performing authentication, authorization, and request field validation. // @@ -47,9 +62,8 @@ import ( // - apiErr.ErrBadInput if request validation fails func guardEncryptCipherRequest( request reqres.CipherEncryptRequest, - peerSPIFFEID *spiffeid.ID, w http.ResponseWriter, - _ *http.Request, + r *http.Request, ) *sdkErrors.SDKError { // Validate plaintext size to prevent DoS attacks if err := validatePlaintextSize( @@ -58,29 +72,9 @@ func guardEncryptCipherRequest( return err } - // Lite Workloads are always allowed: - allowed := false - if sdkSpiffeid.IsLiteWorkload(peerSPIFFEID.String()) { - allowed = true - } - // If not, do a policy check to determine if the request is allowed: - if !allowed { - allowed = state.CheckAccess( - peerSPIFFEID.String(), - apiAuth.PathSystemCipherEncrypt, - []data.PolicyPermission{data.PermissionExecute}, - ) - } - // If not, block the request: - if !allowed { - failErr := net.Fail( - reqres.CipherEncryptResponse{}.Unauthorized(), w, http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized.Clone() - } - - return nil + return net.RespondUnauthorizedOnPredicateFail( + spiffeidAllowedForEncryptCipher, + reqres.CipherEncryptResponse{}.Unauthorized(), + w, r, + ) } diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index ed7d35bc..f9f3cd4c 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -10,6 +10,7 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/net" ) // handleStreamingDecrypt processes a complete streaming mode decryption @@ -36,7 +37,10 @@ func handleStreamingDecrypt( // entity accordingly. // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) + peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( + r, w, reqres.CipherDecryptResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) if err != nil { return err } diff --git a/app/nexus/internal/route/cipher/validation.go b/app/nexus/internal/route/cipher/validation.go index 863a8615..66a1186b 100644 --- a/app/nexus/internal/route/cipher/validation.go +++ b/app/nexus/internal/route/cipher/validation.go @@ -3,132 +3,3 @@ // \\\\\\\ SPDX-License-Identifier: Apache-2.0 package cipher - -import ( - "net/http" - - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - "github.com/spiffe/spike-sdk-go/config/env" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/net" -) - -// extractAndValidateSPIFFEID extracts and validates the peer SPIFFE ID from -// the request without performing authorization checks. This is used as the -// first step before accessing sensitive resources like the cipher. -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the peer SPIFFE ID -// -// Returns: -// - *spiffeid.ID: The validated peer SPIFFE ID (pointer) -// - error: An error if extraction or validation fails -func extractAndValidateSPIFFEID( - w http.ResponseWriter, r *http.Request, -) (*spiffeid.ID, *sdkErrors.SDKError) { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.CipherDecryptResponse]( - r, w, reqres.CipherDecryptResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if alreadyResponded := err != nil; alreadyResponded { - return nil, err - } - - return peerSPIFFEID, nil -} - -// validateVersion validates that the protocol version is supported. -// -// Parameters: -// - version: The protocol version byte to validate -// - w: The HTTP response writer for error responses -// - errorResponse: The error response to send on failure -// -// Returns: -// - nil if the version is valid -// - *sdkErrors.SDKError if the version is unsupported -func validateVersion[T any]( - version byte, w http.ResponseWriter, errorResponse T, -) *sdkErrors.SDKError { - if version != spikeCipherVersion { - failErr := net.Fail(errorResponse, w, http.StatusBadRequest) - if failErr != nil { - return sdkErrors.ErrCryptoUnsupportedCipherVersion.Wrap(failErr) - } - return sdkErrors.ErrCryptoUnsupportedCipherVersion.Clone() - } - return nil -} - -// validateNonceSize validates that the nonce is exactly the expected size. -// -// Parameters: -// - nonce: The nonce bytes to validate -// - w: The HTTP response writer for error responses -// - errorResponse: The error response to send on failure -// -// Returns: -// - nil if the nonce size is valid -// - *sdkErrors.SDKError if the nonce size is invalid -func validateNonceSize[T any]( - nonce []byte, w http.ResponseWriter, errorResponse T, -) *sdkErrors.SDKError { - if len(nonce) != expectedNonceSize { - failErr := net.Fail(errorResponse, w, http.StatusBadRequest) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() - } - return nil -} - -// validateCiphertextSize validates that the ciphertext does not exceed the -// maximum allowed size. -// -// Parameters: -// - ciphertext: The ciphertext bytes to validate -// - w: The HTTP response writer for error responses -// - errorResponse: The error response to send on failure -// -// Returns: -// - nil if the ciphertext size is valid -// - *sdkErrors.SDKError if the ciphertext is too large -func validateCiphertextSize[T any]( - ciphertext []byte, w http.ResponseWriter, errorResponse T, -) *sdkErrors.SDKError { - if len(ciphertext) > env.CryptoMaxCiphertextSizeVal() { - failErr := net.Fail(errorResponse, w, http.StatusBadRequest) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() - } - return nil -} - -// validatePlaintextSize validates that the plaintext does not exceed the -// maximum allowed size. -// -// Parameters: -// - plaintext: The plaintext bytes to validate -// - w: The HTTP response writer for error responses -// - errorResponse: The error response to send on failure -// -// Returns: -// - nil if the plaintext size is valid -// - *sdkErrors.SDKError if the plaintext is too large -func validatePlaintextSize[T any]( - plaintext []byte, w http.ResponseWriter, errorResponse T, -) *sdkErrors.SDKError { - if len(plaintext) > env.CryptoMaxPlaintextSizeVal() { - failErr := net.Fail(errorResponse, w, http.StatusBadRequest) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput - } - return nil -} From 22b91228b747b641e6df17c85b833ea7f6d486d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Tue, 13 Jan 2026 07:11:51 -0800 Subject: [PATCH 03/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/acl/policy/delete_intercept.go | 1 + app/nexus/internal/route/cipher/handle.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 5f2e0978..088a00bd 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -69,6 +69,7 @@ func guardPolicyDeleteRequest( return authErr } + // TODO: RespondErrorOnBadPolicyId (parse policy id from request; do not get as parameter) validationErr := validation.ValidatePolicyID(policyID) if invalidPolicy := validationErr != nil; invalidPolicy { failErr := net.Fail( diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index f9f3cd4c..3e2d5512 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -99,7 +99,9 @@ func handleJSONDecrypt( getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) + peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail(r, w, reqres.CipherDecryptResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) if err != nil { return err } From 129c5cd2272469df91bc7ee1653748ae88db81fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Tue, 13 Jan 2026 21:37:24 -0800 Subject: [PATCH 04/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../route/acl/policy/delete_intercept.go | 32 ++---------- .../route/acl/policy/get_intercept.go | 41 ++++----------- .../internal/route/acl/policy/predicate.go | 12 +++++ .../route/cipher/decrypt_intercept.go | 52 +++++++------------ go.mod | 2 +- go.sum | 6 +-- 6 files changed, 48 insertions(+), 97 deletions(-) create mode 100644 app/nexus/internal/route/acl/policy/predicate.go diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 088a00bd..7dddda18 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -7,24 +7,11 @@ package policy import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - cfg "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" - - state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -func spiffeidAllowedForPolicyDelete(spiffeId string) bool { - return state.CheckAccess( - spiffeId, - cfg.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionWrite}, - ) -} - // guardPolicyDeleteRequest validates a policy deletion request by performing // authentication, authorization, and input validation checks. // @@ -53,7 +40,7 @@ func guardPolicyDeleteRequest( // TODO: ensure this happens in ALL guard calls. // Extract and validate SPIFFE ID before any action. _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - r, w, reqres.PolicyDeleteResponse{ + w, r, reqres.PolicyDeleteResponse{ Err: sdkErrors.ErrAccessUnauthorized.Code, }) if err != nil { @@ -69,18 +56,7 @@ func guardPolicyDeleteRequest( return authErr } - // TODO: RespondErrorOnBadPolicyId (parse policy id from request; do not get as parameter) - validationErr := validation.ValidatePolicyID(policyID) - if invalidPolicy := validationErr != nil; invalidPolicy { - failErr := net.Fail( - reqres.PolicyDeleteResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - validationErr.Msg = "invalid policy ID: " + policyID - if failErr != nil { - return validationErr.Wrap(failErr) - } - return validationErr - } - - return validationErr + return net.RespondErrOnBadPolicyID( + policyID, w, reqres.PolicyDeleteResponse{}.BadRequest(), + ) } diff --git a/app/nexus/internal/route/acl/policy/get_intercept.go b/app/nexus/internal/route/acl/policy/get_intercept.go index 8da93c06..463fc5ec 100644 --- a/app/nexus/internal/route/acl/policy/get_intercept.go +++ b/app/nexus/internal/route/acl/policy/get_intercept.go @@ -12,8 +12,6 @@ import ( apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" - state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -40,40 +38,23 @@ import ( func guardPolicyReadRequest( request reqres.PolicyReadRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.PolicyReadResponse]( - r, w, reqres.PolicyReadResponse{}.Unauthorized(), + _, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.PolicyReadResponse]( + w, r, reqres.PolicyReadResponse{}.Unauthorized(), ) if alreadyResponded := err != nil; alreadyResponded { return err } - policyID := request.ID - - validationErr := validation.ValidatePolicyID(policyID) - if validationErr != nil { - failErr := net.Fail( - reqres.PolicyReadResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return validationErr.Wrap(failErr) - } - validationErr.Msg = "invalid policy ID: " + policyID - return validationErr - } - - allowed := state.CheckAccess( - peerSPIFFEID.String(), apiAuth.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionRead}, + authErr := net.RespondUnauthorizedOnPredicateFail( + spiffeidAllowedForPolicyRead, reqres.PolicyReadResponse{}.Unauthorized(), + w, r, ) - if !allowed { - failErr := net.Fail( - reqres.PolicyReadResponse{}.Unauthorized(), w, http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized.Clone() + if authErr != nil { + return authErr } - return nil + policyID := request.ID + return net.RespondErrOnBadPolicyID( + policyID, w, reqres.PolicyReadResponse{}.BadRequest(), + ) } diff --git a/app/nexus/internal/route/acl/policy/predicate.go b/app/nexus/internal/route/acl/policy/predicate.go new file mode 100644 index 00000000..c47aa08d --- /dev/null +++ b/app/nexus/internal/route/acl/policy/predicate.go @@ -0,0 +1,12 @@ +package policy + +// +//type PolicyAccessChecker func(peerSPIFFEID string, path string, perms []data.PolicyPermission) bool +// +//func spiffeidAllowedForPolicyDelete(peerSPIFFEID string, checkAccess PolicyAccessChecker) bool { +// return checkAccess( +// peerSPIFFEID, +// cfg.PathSystemPolicyAccess, +// []data.PolicyPermission{data.PermissionWrite}, +// ) +//} diff --git a/app/nexus/internal/route/cipher/decrypt_intercept.go b/app/nexus/internal/route/cipher/decrypt_intercept.go index e0ae1991..8dbc84db 100644 --- a/app/nexus/internal/route/cipher/decrypt_intercept.go +++ b/app/nexus/internal/route/cipher/decrypt_intercept.go @@ -7,8 +7,6 @@ package cipher import ( "net/http" - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" apiAuth "github.com/spiffe/spike-sdk-go/config/auth" @@ -46,55 +44,41 @@ import ( // - apiErr.ErrUnauthorized if authorization fails // - apiErr.ErrBadInput if request validation fails func guardDecryptCipherRequest( - request reqres.CipherDecryptRequest, - peerSPIFFEID *spiffeid.ID, - w http.ResponseWriter, - _ *http.Request, + request reqres.CipherDecryptRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + // validate peer SPIFFE ID + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( + w, r, reqres.CipherDecryptResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) + if err != nil { + return err + } + // Validate version - if err := validateVersion( + if err := net.RespondCryptoErrOnVersionMismatch( request.Version, w, reqres.CipherDecryptResponse{}.BadRequest(), ); err != nil { return err } // Validate nonce size - if err := validateNonceSize( + if err := net.RespondCryptoErrOnInvalidNonceSize( request.Nonce, w, reqres.CipherDecryptResponse{}.BadRequest(), ); err != nil { return err } // Validate ciphertext size to prevent DoS attacks - if err := validateCiphertextSize( + if err := net.RespondCryptoErrOnLargeCipherText( request.Ciphertext, w, reqres.CipherDecryptResponse{}.BadRequest(), ); err != nil { return err } - // Lite workloads are always allowed: - allowed := false - if sdkSpiffeid.IsLiteWorkload(peerSPIFFEID.String()) { - allowed = true - } - // If not, do a policy check to determine if the request is allowed: - if !allowed { - allowed = state.CheckAccess( - peerSPIFFEID.String(), - apiAuth.PathSystemCipherDecrypt, - []data.PolicyPermission{data.PermissionExecute}, - ) - } - - if !allowed { - failErr := net.Fail( - reqres.CipherDecryptResponse{}.Unauthorized(), w, http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized.Clone() - } - - return nil + return net.RespondUnauthorizedOnPredicateFail( + spiffeidAllowedForCipherDecrypt, + reqres.CipherDecryptResponse{}.Unauthorized(), + w, r, + ) } diff --git a/go.mod b/go.mod index 95f8bcbd..b78252fc 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.22 + github.com/spiffe/spike-sdk-go v0.17.25 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 5cc7db05..1ba58df9 100644 --- a/go.sum +++ b/go.sum @@ -126,10 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.17.20 h1:9Yc+elwHOl7UJPAib8oVmCve3/NDtxWY91gzw21UajI= -github.com/spiffe/spike-sdk-go v0.17.20/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.21/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.22/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.25 h1:cqEnl8TviY03y7WZX7zqo5aHnAzjN+4t7Sbpz5hK3ZA= +github.com/spiffe/spike-sdk-go v0.17.25/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 08f4600b20be299575928978f5493c0f69ad67b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Tue, 13 Jan 2026 21:54:43 -0800 Subject: [PATCH 05/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../internal/route/acl/policy/delete_intercept.go | 8 +++++++- app/nexus/internal/route/cipher/decrypt_intercept.go | 11 ++++++----- go.mod | 2 +- go.sum | 1 + 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 7dddda18..6c10691c 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -10,6 +10,8 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) // guardPolicyDeleteRequest validates a policy deletion request by performing @@ -49,7 +51,11 @@ func guardPolicyDeleteRequest( // TODO: ensure policy verification before other verifications on ALL guard calls. authErr := net.RespondUnauthorizedOnPredicateFail( - spiffeidAllowedForPolicyDelete, + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForPolicyDelete( + peerSPIFFEID, state.CheckAccess, + ) + }, reqres.PolicyDeleteResponse{}.Unauthorized(), w, r, ) if authErr != nil { diff --git a/app/nexus/internal/route/cipher/decrypt_intercept.go b/app/nexus/internal/route/cipher/decrypt_intercept.go index 8dbc84db..2df982e0 100644 --- a/app/nexus/internal/route/cipher/decrypt_intercept.go +++ b/app/nexus/internal/route/cipher/decrypt_intercept.go @@ -7,13 +7,10 @@ package cipher import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - sdkSpiffeid "github.com/spiffe/spike-sdk-go/spiffeid" - + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -77,7 +74,11 @@ func guardDecryptCipherRequest( } return net.RespondUnauthorizedOnPredicateFail( - spiffeidAllowedForCipherDecrypt, + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForCipherDecrypt( + peerSPIFFEID, state.CheckAccess, + ) + }, reqres.CipherDecryptResponse{}.Unauthorized(), w, r, ) diff --git a/go.mod b/go.mod index b78252fc..35b8ba16 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.25 + github.com/spiffe/spike-sdk-go v0.17.26 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 1ba58df9..dd3801a3 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,7 @@ github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMps github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/spike-sdk-go v0.17.25 h1:cqEnl8TviY03y7WZX7zqo5aHnAzjN+4t7Sbpz5hK3ZA= github.com/spiffe/spike-sdk-go v0.17.25/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.26/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 85d4e3e0449eb4705c6eb822d5b23992795d7c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 03:56:01 -0800 Subject: [PATCH 06/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../route/acl/policy/get_intercept.go | 13 ++++--- .../route/acl/policy/list_intercept.go | 31 +++++++++++----- app/nexus/internal/route/secret/guard.go | 36 ++++++++----------- go.mod | 2 +- go.sum | 2 ++ 5 files changed, 48 insertions(+), 36 deletions(-) diff --git a/app/nexus/internal/route/acl/policy/get_intercept.go b/app/nexus/internal/route/acl/policy/get_intercept.go index 463fc5ec..eb179eb4 100644 --- a/app/nexus/internal/route/acl/policy/get_intercept.go +++ b/app/nexus/internal/route/acl/policy/get_intercept.go @@ -7,11 +7,10 @@ package policy import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -38,7 +37,7 @@ import ( func guardPolicyReadRequest( request reqres.PolicyReadRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - _, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.PolicyReadResponse]( + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail[reqres.PolicyReadResponse]( w, r, reqres.PolicyReadResponse{}.Unauthorized(), ) if alreadyResponded := err != nil; alreadyResponded { @@ -46,8 +45,12 @@ func guardPolicyReadRequest( } authErr := net.RespondUnauthorizedOnPredicateFail( - spiffeidAllowedForPolicyRead, reqres.PolicyReadResponse{}.Unauthorized(), - w, r, + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForPolicyRead( + peerSPIFFEID, state.CheckAccess, + ) + }, + reqres.PolicyReadResponse{}.Unauthorized(), w, r, ) if authErr != nil { return authErr diff --git a/app/nexus/internal/route/acl/policy/list_intercept.go b/app/nexus/internal/route/acl/policy/list_intercept.go index 6df79791..a18a8f5e 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -7,21 +7,20 @@ package policy import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - cfg "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -func hasListPermission(peerSPIFFEID string) bool { - return state.CheckAccess( - peerSPIFFEID, cfg.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionList}, - ) -} +//func hasListPermission(peerSPIFFEID string) bool { +// return state.CheckAccess( +// peerSPIFFEID, cfg.PathSystemPolicyAccess, +// []data.PolicyPermission{data.PermissionList}, +// ) +//} // guardListPolicyRequest validates a policy list request by performing // authentication and authorization checks. @@ -45,6 +44,20 @@ func hasListPermission(peerSPIFFEID string) bool { func guardListPolicyRequest( _ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.RespondUnauthorizedOnPredicateFail(hasListPermission, + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( + w, r, reqres.PolicyListResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) + if err != nil { + return err + } + + return net.RespondUnauthorizedOnPredicateFail( + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForPolicyList( // TODO: update SDK. + peerSPIFFEID, state.CheckAccess, + ) + }, + //hasListPermission, reqres.PolicyListResponse{}.Unauthorized(), w, r) } diff --git a/app/nexus/internal/route/secret/guard.go b/app/nexus/internal/route/secret/guard.go index 6ae4f9cb..5446c3ce 100644 --- a/app/nexus/internal/route/secret/guard.go +++ b/app/nexus/internal/route/secret/guard.go @@ -8,9 +8,10 @@ import ( "net/http" "github.com/spiffe/spike-sdk-go/api/entity/data" + "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -47,33 +48,26 @@ func guardSecretRequest[TUnauth, TBadInput any]( badInputResp TBadInput, ) *sdkErrors.SDKError { // Extract and validate peer SPIFFE ID - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[TUnauth]( - r, w, unauthorizedResp, + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail[TUnauth]( + w, r, unauthorizedResp, ) if alreadyResponded := err != nil; alreadyResponded { return err } // Check access permissions - allowed := state.CheckAccess(peerSPIFFEID.String(), path, permissions) - if !allowed { - failErr := net.Fail(unauthorizedResp, w, http.StatusUnauthorized) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized + authErr := net.RespondUnauthorizedOnPredicateFail( + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForPathAndPermissions( + peerSPIFFEID, path, permissions, state.CheckAccess, + ) + }, + reqres.PolicyDeleteResponse{}.Unauthorized(), w, r, + ) + if authErr != nil { + return authErr } // Validate path format - pathErr := validation.ValidatePath(path) - if pathErr != nil { - failErr := net.Fail(badInputResp, w, http.StatusBadRequest) - pathErr.Msg = "invalid secret path: " + path - if failErr != nil { - return pathErr.Wrap(failErr) - } - return pathErr - } - - return nil + return net.RespondErrOnBadPath(path, badInputResp, w) } diff --git a/go.mod b/go.mod index 35b8ba16..cddb8658 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.26 + github.com/spiffe/spike-sdk-go v0.17.28 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index dd3801a3..351a9c33 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI github.com/spiffe/spike-sdk-go v0.17.25 h1:cqEnl8TviY03y7WZX7zqo5aHnAzjN+4t7Sbpz5hK3ZA= github.com/spiffe/spike-sdk-go v0.17.25/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.26/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.27/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.28/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From e7c30c175da47d7fde1984194240e6dde9de552e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 19:17:22 -0800 Subject: [PATCH 07/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../route/acl/policy/list_intercept.go | 10 +- .../route/acl/policy/put_intercept.go | 96 +++++++------------ .../route/bootstrap/verify_intercept.go | 2 + go.mod | 2 +- 4 files changed, 39 insertions(+), 71 deletions(-) diff --git a/app/nexus/internal/route/acl/policy/list_intercept.go b/app/nexus/internal/route/acl/policy/list_intercept.go index a18a8f5e..5f3474c8 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -15,13 +15,6 @@ import ( state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -//func hasListPermission(peerSPIFFEID string) bool { -// return state.CheckAccess( -// peerSPIFFEID, cfg.PathSystemPolicyAccess, -// []data.PolicyPermission{data.PermissionList}, -// ) -//} - // guardListPolicyRequest validates a policy list request by performing // authentication and authorization checks. // @@ -54,10 +47,9 @@ func guardListPolicyRequest( return net.RespondUnauthorizedOnPredicateFail( func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPolicyList( // TODO: update SDK. + return predicate.AllowSPIFFEIDForPolicyList( peerSPIFFEID, state.CheckAccess, ) }, - //hasListPermission, reqres.PolicyListResponse{}.Unauthorized(), w, r) } diff --git a/app/nexus/internal/route/acl/policy/put_intercept.go b/app/nexus/internal/route/acl/policy/put_intercept.go index f0df2181..e621f944 100644 --- a/app/nexus/internal/route/acl/policy/put_intercept.go +++ b/app/nexus/internal/route/acl/policy/put_intercept.go @@ -7,23 +7,13 @@ package policy import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - cfg "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" - + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -func hasWritePermission(peerSPIFFEID string) bool { - return state.CheckAccess( - peerSPIFFEID, cfg.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionWrite}, - ) -} - // guardPolicyCreateRequest validates a policy creation request by performing // authentication, authorization, and input validation checks. // @@ -50,69 +40,53 @@ func hasWritePermission(peerSPIFFEID string) bool { func guardPolicyCreateRequest( request reqres.PolicyPutRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - err := net.RespondUnauthorizedOnPredicateFail( - hasWritePermission, - reqres.PolicyPutResponse{}.Unauthorized(), w, r, - ) + _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( + w, r, reqres.PolicyPutResponse{ + Err: sdkErrors.ErrAccessUnauthorized.Code, + }) if err != nil { return err } + writeErr := net.RespondUnauthorizedOnPredicateFail( + func(peerSPIFFEID string) bool { + return predicate.AllowSPIFFEIDForPolicyWrite( + peerSPIFFEID, state.CheckAccess, + ) + }, + reqres.PolicyPutResponse{}.Unauthorized(), w, r, + ) + if writeErr != nil { + return writeErr + } + name := request.Name SPIFFEIDPattern := request.SPIFFEIDPattern pathPattern := request.PathPattern permissions := request.Permissions - if err := validation.ValidateName(name); err != nil { - failErr := net.Fail( - reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() - } - - if err := validation.ValidateSPIFFEIDPattern(SPIFFEIDPattern); err != nil { - failEr := net.Fail( - reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failEr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failEr) - } - return sdkErrors.ErrDataInvalidInput.Clone() + nameErr := net.RespondErrOnBadName( + name, reqres.PolicyPutResponse{}.BadRequest(), w, + ) + if nameErr != nil { + return nameErr } - if err := validation.ValidatePathPattern(pathPattern); err != nil { - failErr := net.Fail( - reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() + spifeIdPatternErr := net.RespondErrOnBadSPIFFEIDPattern( + SPIFFEIDPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ) + if spifeIdPatternErr != nil { + return spifeIdPatternErr } - if len(permissions) == 0 { - failErr := net.Fail( - reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() - } - for _, perm := range permissions { - if !validation.ValidPermission(string(perm)) { - failErr := net.Fail( - reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() - } + pathPatternErr := net.RespondErrOnBadPathPattern( + pathPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ) + if pathPatternErr != nil { + return pathPatternErr } - return nil + return net.RespondErrOnBadPermission( + permissions, reqres.PolicyPutResponse{}.BadRequest(), w, + ) } diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 809236d8..9e828d5b 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,6 +51,8 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + // -------<<<<<<<<<>>>>> + err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) if err != nil { diff --git a/go.mod b/go.mod index cddb8658..015e8d3d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.28 + github.com/spiffe/spike-sdk-go v0.17.30 golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 From 4c7b72adaaf945b30b034753cde4070cf36e78b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 19:46:38 -0800 Subject: [PATCH 08/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.sum b/go.sum index 351a9c33..c9c53382 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/spiffe/spike-sdk-go v0.17.25/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2N github.com/spiffe/spike-sdk-go v0.17.26/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.27/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.28/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.29/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.30/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From fc32b4d3d2f575eb0244cf15c0e955d8044fa4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 19:50:15 -0800 Subject: [PATCH 09/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 9e828d5b..57185907 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>> + // -------<<<<<<<<<>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From c6455ab17ab5401c7725c531990ffd8fb6efaa95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 19:52:13 -0800 Subject: [PATCH 10/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 57185907..d98f12af 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>>>> + // -------<<<<<<<<<>>>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From bd5cc268176ff8fd508451de2a9e01502d4ef379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 19:54:11 -0800 Subject: [PATCH 11/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index d98f12af..6ec53dca 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>>>>>> + // -------<<<<<<<<<>>>>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From c75b301c19b8c6b978bd5372f3c00cdb97c43ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 20:00:04 -0800 Subject: [PATCH 12/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 6ec53dca..2c922793 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>>>>>>> + // -------<<<<<<<<<>>>>>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From 7180c2f37ba6d8c730c5b05ffc6a98c4d62f7049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 20:24:27 -0800 Subject: [PATCH 13/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 2c922793..679e6c20 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>>>>>>>> + // -------<<<<<<<<<>>>>>>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From 622ca7b9f8d37841a52622979cafb7fe2096534d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 21:00:11 -0800 Subject: [PATCH 14/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 679e6c20..a89e0ecd 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // -------<<<<<<<<<>>>>>>>>>>>> + // >>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From e0381ad7b30193d8ec0fa0ba70f78e868ee98dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 21:04:19 -0800 Subject: [PATCH 15/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index a89e0ecd..2684cf8b 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,7 +51,7 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // >>>>>> + // >>>>>>>>>> err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) From d94cff57127d7f853555121fc9da45456b5b156e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Wed, 14 Jan 2026 21:05:31 -0800 Subject: [PATCH 16/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/bootstrap/verify_intercept.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 2684cf8b..809236d8 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -51,8 +51,6 @@ const expectedNonceSize = crypto.GCMNonceSize func guardVerifyRequest( request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // >>>>>>>>>> - err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) if err != nil { From f38b8b95a39397b77bb953d529ac0011d5c09cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Thu, 15 Jan 2026 03:36:50 -0800 Subject: [PATCH 17/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code does not compile. Signed-off-by: Volkan Özçelik --- app/keeper/internal/route/doc.go | 75 +++++++++++++++++ .../route/acl/policy/delete_intercept.go | 31 ++----- .../route/acl/policy/get_intercept.go | 25 ++---- .../route/acl/policy/list_intercept.go | 21 ++--- app/nexus/internal/route/acl/policy/put.go | 2 +- .../route/acl/policy/put_intercept.go | 58 +++++--------- .../route/bootstrap/verify_intercept.go | 52 +++++------- .../route/cipher/decrypt_intercept.go | 44 ++++------ app/nexus/internal/route/cipher/doc.go | 2 +- .../route/cipher/encrypt_intercept.go | 24 +++--- app/nexus/internal/route/cipher/handle.go | 8 +- app/nexus/internal/route/doc.go | 80 +++++++++++++++++++ .../internal/route/secret/list_intercept.go | 2 +- app/nexus/internal/state/base/policy.go | 28 ++++--- go.mod | 12 +-- go.sum | 14 ++++ 16 files changed, 289 insertions(+), 189 deletions(-) create mode 100644 app/keeper/internal/route/doc.go create mode 100644 app/nexus/internal/route/doc.go diff --git a/app/keeper/internal/route/doc.go b/app/keeper/internal/route/doc.go new file mode 100644 index 00000000..145452f7 --- /dev/null +++ b/app/keeper/internal/route/doc.go @@ -0,0 +1,75 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +// Package route contains HTTP route handlers for SPIKE Keeper API endpoints. +// +// SPIKE Keeper is a shard storage service that holds Shamir secret shares for +// disaster recovery. Unlike SPIKE Nexus, which manages secrets and policies, +// Keeper has a focused responsibility: securely storing and serving shards +// that can be combined to reconstruct the root key. +// +// This package is organized into sub-packages: +// +// - base: Core routing logic and request dispatching +// - store: Shard storage endpoints (contribute, retrieve) +// +// # Endpoints +// +// Keeper exposes two endpoints: +// +// - Contribute: Accepts shard contributions from SPIKE Bootstrap (during +// initial setup) or SPIKE Nexus (during periodic updates). Validates +// that the shard is non-nil and non-zero before storing. +// +// - Shard: Returns the stored shard to SPIKE Nexus during recovery +// operations. Only Nexus is authorized to retrieve shards. +// +// # Authentication Model +// +// All routes require mTLS authentication via SPIFFE. The caller's SPIFFE ID is +// extracted from the client certificate and validated before any operations are +// performed. This provides strong cryptographic identity verification at the +// transport layer. +// +// # Authorization Model +// +// Unlike SPIKE Nexus, Keeper does not use policy-based authorization. All +// routes are identity-restricted, requiring exact SPIFFE ID matches: +// +// - Contribute: Accepts requests from SPIKE Bootstrap or SPIKE Nexus only +// - Shard: Accepts requests from SPIKE Nexus only +// +// This strict identity-based model is appropriate because Keeper handles +// sensitive key material that should never be accessible to arbitrary +// workloads, regardless of any policy configuration. +// +// # Error Response Design +// +// Route handlers return distinct HTTP status codes for authentication failures +// (401 Unauthorized) versus input validation failures (400 Bad Request). This +// follows the same rationale as SPIKE Nexus routes: the asymmetry is acceptable +// because mTLS + SPIFFE ID verification forms a strong authentication boundary, +// and distinct error codes aid operational debugging without meaningful +// information leakage. +// +// See the SPIKE Nexus route package documentation for the full security +// analysis of this design decision. +// +// # Security Properties +// +// Keeper implements additional security measures for handling sensitive shard +// data: +// +// - Memory clearing: Shard data is zeroed from memory after use to minimize +// exposure window +// - Input validation: Shards are validated to be non-nil and non-zero before +// storage +// - Audit logging: All operations are logged for security auditing +// +// # Interceptors +// +// Each route handler is paired with an interceptor file (e.g., shard.go and +// shard_intercept.go). Interceptors contain guard functions that perform +// authentication checks before the main handler logic executes. +package route diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 6c10691c..668b764b 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -11,6 +11,7 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -37,32 +38,16 @@ import ( func guardPolicyDeleteRequest( request reqres.PolicyDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - policyID := request.ID - - // TODO: ensure this happens in ALL guard calls. - // Extract and validate SPIFFE ID before any action. - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - w, r, reqres.PolicyDeleteResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err - } - - // TODO: ensure policy verification before other verifications on ALL guard calls. - authErr := net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPolicyDelete( - peerSPIFFEID, state.CheckAccess, - ) - }, - reqres.PolicyDeleteResponse{}.Unauthorized(), w, r, - ) - if authErr != nil { + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyDeleteResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyDelete, + state.CheckAccess, + w, r, + ); authErr != nil { return authErr } return net.RespondErrOnBadPolicyID( - policyID, w, reqres.PolicyDeleteResponse{}.BadRequest(), + request.ID, w, reqres.PolicyDeleteResponse{}.BadRequest(), ) } diff --git a/app/nexus/internal/route/acl/policy/get_intercept.go b/app/nexus/internal/route/acl/policy/get_intercept.go index eb179eb4..6a9017d1 100644 --- a/app/nexus/internal/route/acl/policy/get_intercept.go +++ b/app/nexus/internal/route/acl/policy/get_intercept.go @@ -37,27 +37,16 @@ import ( func guardPolicyReadRequest( request reqres.PolicyReadRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail[reqres.PolicyReadResponse]( - w, r, reqres.PolicyReadResponse{}.Unauthorized(), - ) - if alreadyResponded := err != nil; alreadyResponded { - return err - } - - authErr := net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPolicyRead( - peerSPIFFEID, state.CheckAccess, - ) - }, - reqres.PolicyReadResponse{}.Unauthorized(), w, r, - ) - if authErr != nil { + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyReadResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyRead, + state.CheckAccess, + w, r, + ); authErr != nil { return authErr } - policyID := request.ID return net.RespondErrOnBadPolicyID( - policyID, w, reqres.PolicyReadResponse{}.BadRequest(), + request.ID, w, reqres.PolicyReadResponse{}.BadRequest(), ) } diff --git a/app/nexus/internal/route/acl/policy/list_intercept.go b/app/nexus/internal/route/acl/policy/list_intercept.go index 5f3474c8..fae9b44f 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -37,19 +37,10 @@ import ( func guardListPolicyRequest( _ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - w, r, reqres.PolicyListResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err - } - - return net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPolicyList( - peerSPIFFEID, state.CheckAccess, - ) - }, - reqres.PolicyListResponse{}.Unauthorized(), w, r) + return net.AuthorizeAndRespondOnFail( + reqres.PolicyListResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyList, + state.CheckAccess, + w, r, + ) } diff --git a/app/nexus/internal/route/acl/policy/put.go b/app/nexus/internal/route/acl/policy/put.go index 5aadb126..0ae06c5e 100644 --- a/app/nexus/internal/route/acl/policy/put.go +++ b/app/nexus/internal/route/acl/policy/put.go @@ -71,7 +71,7 @@ func RoutePutPolicy( request, err := net.ReadParseAndGuard[ reqres.PolicyPutRequest, reqres.PolicyPutResponse, ]( - w, r, reqres.PolicyPutResponse{}.BadRequest(), guardPolicyCreateRequest, + w, r, reqres.PolicyPutResponse{}.BadRequest(), guardPolicyPutRequest, ) if alreadyResponded := err != nil; alreadyResponded { return err diff --git a/app/nexus/internal/route/acl/policy/put_intercept.go b/app/nexus/internal/route/acl/policy/put_intercept.go index e621f944..41cbca64 100644 --- a/app/nexus/internal/route/acl/policy/put_intercept.go +++ b/app/nexus/internal/route/acl/policy/put_intercept.go @@ -11,10 +11,11 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -// guardPolicyCreateRequest validates a policy creation request by performing +// guardPolicyPutRequest validates a policy creation request by performing // authentication, authorization, and input validation checks. // // The function performs the following validations in order: @@ -37,56 +38,37 @@ import ( // - nil if all validations pass // - apiErr.ErrUnauthorized if authentication or authorization fails // - apiErr.ErrInvalidInput if any input validation fails -func guardPolicyCreateRequest( +func guardPolicyPutRequest( request reqres.PolicyPutRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - w, r, reqres.PolicyPutResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err - } - - writeErr := net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPolicyWrite( - peerSPIFFEID, state.CheckAccess, - ) - }, - reqres.PolicyPutResponse{}.Unauthorized(), w, r, - ) - if writeErr != nil { - return writeErr + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyPutResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyWrite, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } - name := request.Name - SPIFFEIDPattern := request.SPIFFEIDPattern - pathPattern := request.PathPattern - permissions := request.Permissions - - nameErr := net.RespondErrOnBadName( - name, reqres.PolicyPutResponse{}.BadRequest(), w, - ) - if nameErr != nil { + if nameErr := net.RespondErrOnBadName( + request.Name, reqres.PolicyPutResponse{}.BadRequest(), w, + ); nameErr != nil { return nameErr } - spifeIdPatternErr := net.RespondErrOnBadSPIFFEIDPattern( - SPIFFEIDPattern, reqres.PolicyPutResponse{}.BadRequest(), w, - ) - if spifeIdPatternErr != nil { + if spifeIdPatternErr := net.RespondErrOnBadSPIFFEIDPattern( + request.SPIFFEIDPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ); spifeIdPatternErr != nil { return spifeIdPatternErr } - pathPatternErr := net.RespondErrOnBadPathPattern( - pathPattern, reqres.PolicyPutResponse{}.BadRequest(), w, - ) - if pathPatternErr != nil { + if pathPatternErr := net.RespondErrOnBadPathPattern( + request.PathPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ); pathPatternErr != nil { return pathPatternErr } return net.RespondErrOnBadPermission( - permissions, reqres.PolicyPutResponse{}.BadRequest(), w, + request.Permissions, reqres.PolicyPutResponse{}.BadRequest(), w, ) } diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 809236d8..a54b282a 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -8,12 +8,10 @@ import ( "net/http" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - "github.com/spiffe/spike-sdk-go/config/env" + "github.com/spiffe/spike-sdk-go/crypto" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/spiffeid" - - "github.com/spiffe/spike-sdk-go/crypto" ) // expectedNonceSize is the standard AES-GCM nonce size. See ADR-0032. @@ -49,38 +47,30 @@ const expectedNonceSize = crypto.GCMNonceSize // bootstrap // - sdkErrors.ErrDataInvalidInput if nonce or ciphertext validation fails func guardVerifyRequest( - request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request, + request reqres.BootstrapVerifyRequest, + w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, - reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) - if err != nil { - return err + // No CheckAccess because this route is privileged and should not honor + // policy overrides. Match exact SPIFFE ID instead. + if _, idErr := net.ExtractPeerSPIFFEIDAndRespondOnFail( + w, r, reqres.BootstrapVerifyResponse{}.Unauthorized(), + ); idErr != nil { + return idErr } - - if len(request.Nonce) != expectedNonceSize { - failErr := net.Fail( - reqres.BootstrapVerifyResponse{}.BadRequest(), w, - http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() + authErr := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, + reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) + if authErr != nil { + return authErr } - // Limit cipherText size to prevent DoS attacks - // The maximum possible size is 68,719,476,704 - // The limit comes from GCM's 32-bit counter. - if len(request.Ciphertext) > env.CryptoMaxCiphertextSizeVal() { - failErr := net.Fail( - reqres.BootstrapVerifyResponse{}.BadRequest(), w, - http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrDataInvalidInput.Wrap(failErr) - } - return sdkErrors.ErrDataInvalidInput.Clone() + nonceErr := net.RespondCryptoErrOnInvalidNonceSize( + request.Nonce, w, reqres.BootstrapVerifyResponse{}.BadRequest(), + ) + if nonceErr != nil { + return nonceErr } - return nil + return net.RespondCryptoErrOnLargeCipherText( + request.Ciphertext, w, reqres.BootstrapVerifyResponse{}.BadRequest(), + ) } diff --git a/app/nexus/internal/route/cipher/decrypt_intercept.go b/app/nexus/internal/route/cipher/decrypt_intercept.go index 2df982e0..f246c00c 100644 --- a/app/nexus/internal/route/cipher/decrypt_intercept.go +++ b/app/nexus/internal/route/cipher/decrypt_intercept.go @@ -14,7 +14,7 @@ import ( state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -// guardDecryptCipherRequest validates a cipher decryption request by +// guardCipherDecryptRequest validates a cipher decryption request by // performing authentication, authorization, and request field validation. // // This function implements a two-tier authorization model: @@ -40,46 +40,34 @@ import ( // - nil if all validations pass // - apiErr.ErrUnauthorized if authorization fails // - apiErr.ErrBadInput if request validation fails -func guardDecryptCipherRequest( +func guardCipherDecryptRequest( request reqres.CipherDecryptRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // validate peer SPIFFE ID - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - w, r, reqres.CipherDecryptResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } // Validate version - if err := net.RespondCryptoErrOnVersionMismatch( + if versionErr := net.RespondCryptoErrOnVersionMismatch( request.Version, w, reqres.CipherDecryptResponse{}.BadRequest(), - ); err != nil { - return err + ); versionErr != nil { + return versionErr } // Validate nonce size - if err := net.RespondCryptoErrOnInvalidNonceSize( + if nonceErr := net.RespondCryptoErrOnInvalidNonceSize( request.Nonce, w, reqres.CipherDecryptResponse{}.BadRequest(), - ); err != nil { - return err + ); nonceErr != nil { + return nonceErr } // Validate ciphertext size to prevent DoS attacks - if err := net.RespondCryptoErrOnLargeCipherText( + return net.RespondCryptoErrOnLargeCipherText( request.Ciphertext, w, reqres.CipherDecryptResponse{}.BadRequest(), - ); err != nil { - return err - } - - return net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForCipherDecrypt( - peerSPIFFEID, state.CheckAccess, - ) - }, - reqres.CipherDecryptResponse{}.Unauthorized(), - w, r, ) } diff --git a/app/nexus/internal/route/cipher/doc.go b/app/nexus/internal/route/cipher/doc.go index 982319a6..ad495703 100644 --- a/app/nexus/internal/route/cipher/doc.go +++ b/app/nexus/internal/route/cipher/doc.go @@ -99,7 +99,7 @@ // // To add request field validation, modify the guard functions: // -// func guardDecryptCipherRequest( +// func guardCipherDecryptRequest( // request reqres.CipherDecryptRequest, // peerSPIFFEID *spiffeid.ID, // w http.ResponseWriter, diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index b08c0aac..c82ac9bb 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -12,6 +12,7 @@ import ( apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" sdkSpiffeid "github.com/spiffe/spike-sdk-go/spiffeid" state "github.com/spiffe/spike/app/nexus/internal/state/base" @@ -34,7 +35,7 @@ func spiffeidAllowedForEncryptCipher(spiffeid string) bool { return allowed } -// guardEncryptCipherRequest validates a cipher encryption request by +// guardCipherEncryptRequest validates a cipher encryption request by // performing authentication, authorization, and request field validation. // // This function implements a two-tier authorization model: @@ -60,21 +61,22 @@ func spiffeidAllowedForEncryptCipher(spiffeid string) bool { // - nil if all validations pass // - apiErr.ErrUnauthorized if authorization fails // - apiErr.ErrBadInput if request validation fails -func guardEncryptCipherRequest( +func guardCipherEncryptRequest( request reqres.CipherEncryptRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - // Validate plaintext size to prevent DoS attacks - if err := validatePlaintextSize( - request.Plaintext, w, reqres.CipherEncryptResponse{}.BadRequest(), - ); err != nil { - return err - } - - return net.RespondUnauthorizedOnPredicateFail( - spiffeidAllowedForEncryptCipher, + if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherEncryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherEncrypt, // TODO: needs SDK update. + state.CheckAccess, w, r, + ); authErr != nil { + return authErr + } + + // Validate plaintext size to prevent DoS attacks + return net.RespondCryptoErrOnLargeCipherText( + request.Plaintext, w, reqres.CipherEncryptResponse{}.BadRequest(), ) } diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index 3e2d5512..6fdba070 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -67,7 +67,7 @@ func handleStreamingDecrypt( } // Full guard validation (auth and request fields) - guardErr := guardDecryptCipherRequest(request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(request, peerSPIFFEID, w, r) if guardErr != nil { return guardErr } @@ -113,7 +113,7 @@ func handleJSONDecrypt( } // Full guard validation (auth and request fields) - guardErr := guardDecryptCipherRequest(*request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(*request, peerSPIFFEID, w, r) if guardErr != nil { return guardErr } @@ -170,7 +170,7 @@ func handleStreamingEncrypt( } // Full guard validation (auth and request fields) - guardErr := guardEncryptCipherRequest(request, peerSPIFFEID, w, r) + guardErr := guardCipherEncryptRequest(request, peerSPIFFEID, w, r) if guardErr != nil { return guardErr } @@ -220,7 +220,7 @@ func handleJSONEncrypt( } // Full guard validation (auth and request fields) - guardErr := guardEncryptCipherRequest(*request, peerSPIFFEID, w, r) + guardErr := guardCipherEncryptRequest(*request, peerSPIFFEID, w, r) if guardErr != nil { return guardErr } diff --git a/app/nexus/internal/route/doc.go b/app/nexus/internal/route/doc.go new file mode 100644 index 00000000..dd94f82e --- /dev/null +++ b/app/nexus/internal/route/doc.go @@ -0,0 +1,80 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +// Package route contains HTTP route handlers for SPIKE Nexus API endpoints. +// +// This package is organized into sub-packages by functional domain: +// +// - base: Core routing logic, request dispatching, and guard utilities +// - bootstrap: Bootstrap verification endpoints (proof-of-possession protocol) +// - cipher: Encryption and decryption endpoints (encryption-as-a-service) +// - operator: Disaster recovery endpoints (recover, restore) +// - secret: Secret management endpoints (CRUD operations with versioning) +// - acl/policy: Access control policy management endpoints +// +// # Authentication Model +// +// All routes require mTLS authentication via SPIFFE. The caller's SPIFFE ID is +// extracted from the client certificate and validated before any operations are +// performed. This provides strong cryptographic identity verification at the +// transport layer. +// +// # Authorization Model +// +// Routes fall into two authorization categories: +// +// 1. Policy-based routes (secret, cipher, acl/policy): +// These routes use CheckAccess to evaluate the caller's SPIFFE ID against +// defined policies. Workloads with matching policies can access resources +// based on their granted permissions (read, write, list, super). +// +// 2. Identity-restricted routes (bootstrap, operator): +// These routes require exact SPIFFE ID matches and do not honor policy +// overrides. Recovery and restore operations, for example, are restricted +// to specific SPIKE Pilot identities to prevent unauthorized access to +// sensitive key material. +// +// # Error Response Design +// +// Route handlers return distinct HTTP status codes for authentication failures +// (401 Unauthorized) versus input validation failures (400 Bad Request). While +// security purists might prefer uniform error responses to prevent information +// leakage, this asymmetry is acceptable in SPIKE's threat model for the +// following reasons: +// +// - No enumeration attack surface: Unlike username/password authentication +// where distinct errors help enumerate valid accounts, here the caller +// already knows their own SPIFFE ID. They cannot probe for "valid" +// identities since identity is cryptographically bound to their SVID. +// +// - Binary authorization: Identity checks are "you are X or you are not." +// An attacker with a non-matching SVID learns nothing useful from an +// Unauthorized response since they already know their own identity. +// +// - mTLS is the real gate: Attackers without a valid SVID from the trust +// domain cannot even establish a connection. HTTP response codes are +// only visible to entities that have already passed a strong +// authentication boundary. +// +// - Operational benefit: Distinct error codes help legitimate workloads +// debug issues. "Unauthorized" indicates an identity mismatch while +// "Bad Request" indicates malformed input. Conflating these would +// trade marginal theoretical security for real operational pain. +// +// # Request Handling Patterns +// +// Most routes use the embedded guard pattern via net.ReadParseAndGuard, which +// combines request reading, JSON parsing, and authorization in a single +// operation. The cipher package uses a separate guard pattern due to its +// unique streaming mode requirements; see the cipher package documentation +// for details. +// +// # Interceptors +// +// Each route handler is typically paired with an interceptor file (e.g., +// get.go and get_intercept.go). Interceptors contain guard functions that +// perform authentication and authorization checks before the main handler +// logic executes. This separation keeps security logic distinct from +// business logic. +package route diff --git a/app/nexus/internal/route/secret/list_intercept.go b/app/nexus/internal/route/secret/list_intercept.go index b9688b4b..0fd0ac44 100644 --- a/app/nexus/internal/route/secret/list_intercept.go +++ b/app/nexus/internal/route/secret/list_intercept.go @@ -42,7 +42,7 @@ import ( func guardListSecretRequest( _ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.SecretListResponse]( + peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( r, w, reqres.SecretListResponse{}.Unauthorized(), ) if err != nil { diff --git a/app/nexus/internal/state/base/policy.go b/app/nexus/internal/state/base/policy.go index 2a420214..9a4e3ad7 100644 --- a/app/nexus/internal/state/base/policy.go +++ b/app/nexus/internal/state/base/policy.go @@ -19,10 +19,16 @@ import ( ) // CheckAccess determines if a given SPIFFE ID has the required permissions for -// a specific path. It first checks if the ID belongs to SPIKE Pilot (which has -// unrestricted access), then evaluates against all defined policies. Policies -// are checked in order, with wildcard patterns evaluated first, followed by -// specific pattern matching using regular expressions. +// a specific path. For SPIKE Pilot (a system workload), access is always +// granted without policy checks. For other workloads, the function evaluates +// against all defined policies using regular expression pattern matching. +// +// Workloads with associated policies effectively act "on behalf of" SPIKE Pilot +// to read and modify secrets and policies. +// +// Note that elevated actions such as "recovery" and "restore" DO NOT use +// CheckAccess for access control. These actions require exact actor SPIFFE ID +// matches and cannot be overridden by policies. // // Parameters: // - spiffeId: The SPIFFE ID of the requestor @@ -34,19 +40,17 @@ import ( // // The function grants access if any of these conditions are met: // 1. The requestor is a SPIKE Pilot instance. -// 2. A matching policy has the super permission -// 3. A matching policy contains all requested permissions +// 2. A matching policy has the super permission. +// 3. A matching policy contains all requested permissions. // -// A policy matches when: -// -// Its SPIFFE ID pattern matches the requestor's ID, and its path pattern -// matches the requested path. +// A policy matches when its SPIFFE ID pattern matches the requestor's ID and +// its path pattern matches the requested path. func CheckAccess( peerSPIFFEID string, path string, wants []data.PolicyPermission, ) bool { const fName = "CheckAccess" - // Role:SpikePilot can always manage secrets and policies, - // and can call encryption and decryption API endpoints. + + // SPIKE Pilot is a system workload; no policy check needed. if spiffeid.IsPilotOperator(peerSPIFFEID) { return true } diff --git a/go.mod b/go.mod index 015e8d3d..85f8fb6c 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.30 - golang.org/x/term v0.38.0 + github.com/spiffe/spike-sdk-go v0.17.31 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 @@ -51,13 +51,13 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index c9c53382..244b6096 100644 --- a/go.sum +++ b/go.sum @@ -133,6 +133,8 @@ github.com/spiffe/spike-sdk-go v0.17.27/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2N github.com/spiffe/spike-sdk-go v0.17.28/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.29/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.30/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.31 h1:wafqzzexieCzNCi39VFx1+qOxl8cRM52n1qdjcN7jOc= +github.com/spiffe/spike-sdk-go v0.17.31/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -162,12 +164,15 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -188,11 +193,17 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -201,6 +212,7 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -210,6 +222,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= From a6bbaac8cfaf38043582501b5b2af84ced6a05dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Thu, 15 Jan 2026 09:58:37 -0800 Subject: [PATCH 18/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code does not compile. Signed-off-by: Volkan Özçelik --- .../route/cipher/encrypt_intercept.go | 2 +- app/nexus/internal/route/cipher/handle.go | 57 +++-- app/nexus/internal/route/cipher/validation.go | 5 - .../internal/route/cipher/validation_test.go | 212 ------------------ .../route/operator/recover_intercept.go | 2 + .../route/operator/restore_intercept.go | 7 +- .../internal/route/secret/list_intercept.go | 10 + go.mod | 2 +- go.sum | 1 + 9 files changed, 55 insertions(+), 243 deletions(-) delete mode 100644 app/nexus/internal/route/cipher/validation.go delete mode 100644 app/nexus/internal/route/cipher/validation_test.go diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index c82ac9bb..2ddbd515 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -68,7 +68,7 @@ func guardCipherEncryptRequest( ) *sdkErrors.SDKError { if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherEncryptResponse{}.Unauthorized(), - predicate.AllowSPIFFEIDForCipherEncrypt, // TODO: needs SDK update. + predicate.AllowSPIFFEIDForCipherEncrypt, state.CheckAccess, w, r, ); authErr != nil { diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index 6fdba070..9083edb6 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -11,6 +11,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" + + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) // handleStreamingDecrypt processes a complete streaming mode decryption @@ -36,13 +39,13 @@ func handleStreamingDecrypt( // payloads. We need to read the entire stream and generate a request // entity accordingly. - // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - r, w, reqres.CipherDecryptResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } // Get cipher only after SPIFFE ID validation passes @@ -67,7 +70,7 @@ func handleStreamingDecrypt( } // Full guard validation (auth and request fields) - guardErr := guardCipherDecryptRequest(request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(request, w, r) if guardErr != nil { return guardErr } @@ -99,11 +102,13 @@ func handleJSONDecrypt( getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail(r, w, reqres.CipherDecryptResponse{ - Err: sdkErrors.ErrAccessUnauthorized.Code, - }) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } // Parse request (doesn't need cipher) @@ -113,7 +118,7 @@ func handleJSONDecrypt( } // Full guard validation (auth and request fields) - guardErr := guardCipherDecryptRequest(*request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(*request, w, r) if guardErr != nil { return guardErr } @@ -153,9 +158,13 @@ func handleStreamingEncrypt( getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherEncryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherEncrypt, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } // Read plaintext (doesn't need cipher) @@ -170,7 +179,7 @@ func handleStreamingEncrypt( } // Full guard validation (auth and request fields) - guardErr := guardCipherEncryptRequest(request, peerSPIFFEID, w, r) + guardErr := guardCipherEncryptRequest(request, w, r) if guardErr != nil { return guardErr } @@ -208,9 +217,13 @@ func handleJSONEncrypt( getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherEncryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherEncrypt, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } // Parse request (doesn't need cipher) @@ -220,7 +233,7 @@ func handleJSONEncrypt( } // Full guard validation (auth and request fields) - guardErr := guardCipherEncryptRequest(*request, peerSPIFFEID, w, r) + guardErr := guardCipherEncryptRequest(*request, w, r) if guardErr != nil { return guardErr } diff --git a/app/nexus/internal/route/cipher/validation.go b/app/nexus/internal/route/cipher/validation.go deleted file mode 100644 index 66a1186b..00000000 --- a/app/nexus/internal/route/cipher/validation.go +++ /dev/null @@ -1,5 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher diff --git a/app/nexus/internal/route/cipher/validation_test.go b/app/nexus/internal/route/cipher/validation_test.go deleted file mode 100644 index 5142f075..00000000 --- a/app/nexus/internal/route/cipher/validation_test.go +++ /dev/null @@ -1,212 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/spiffe/spike-sdk-go/config/env" -) - -type testErrorResponse struct { - Err string `json:"err"` -} - -func TestValidateVersion_ValidVersion(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "invalid version"} - - err := validateVersion(spikeCipherVersion, w, errResp) - - if err != nil { - t.Errorf("validateVersion() error = %v, want nil", err) - } - - if w.Code != http.StatusOK { - t.Errorf("validateVersion() status = %d, want %d", w.Code, http.StatusOK) - } -} - -func TestValidateVersion_InvalidVersion(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "invalid version"} - - err := validateVersion(byte('9'), w, errResp) - - if err == nil { - t.Error("validateVersion() expected error for invalid version") - } - - if w.Code != http.StatusBadRequest { - t.Errorf("validateVersion() status = %d, want %d", - w.Code, http.StatusBadRequest) - } -} - -func TestValidateNonceSize_ValidNonce(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "invalid nonce"} - // expectedNonceSize is 12 bytes for AES-GCM - nonce := make([]byte, expectedNonceSize) - - err := validateNonceSize(nonce, w, errResp) - - if err != nil { - t.Errorf("validateNonceSize() error = %v, want nil", err) - } - - if w.Code != http.StatusOK { - t.Errorf("validateNonceSize() status = %d, want %d", - w.Code, http.StatusOK) - } -} - -func TestValidateNonceSize_InvalidNonce(t *testing.T) { - tests := []struct { - name string - nonceSize int - }{ - {"too short", 8}, - {"too long", 16}, - {"empty", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "invalid nonce"} - nonce := make([]byte, tt.nonceSize) - - err := validateNonceSize(nonce, w, errResp) - - if err == nil { - t.Error("validateNonceSize() expected error for invalid nonce size") - } - - if w.Code != http.StatusBadRequest { - t.Errorf("validateNonceSize() status = %d, want %d", - w.Code, http.StatusBadRequest) - } - }) - } -} - -func TestValidateCiphertextSize_ValidSize(t *testing.T) { - tests := []struct { - name string - size int - }{ - {"small", 100}, - {"medium", 1000}, - {"at max", env.CryptoMaxCiphertextSizeVal()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "ciphertext too large"} - ciphertext := make([]byte, tt.size) - - err := validateCiphertextSize(ciphertext, w, errResp) - - if err != nil { - t.Errorf("validateCiphertextSize() error = %v, want nil", err) - } - - if w.Code != http.StatusOK { - t.Errorf("validateCiphertextSize() status = %d, want %d", - w.Code, http.StatusOK) - } - }) - } -} - -func TestValidateCiphertextSize_TooLarge(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "ciphertext too large"} - ciphertext := make([]byte, env.CryptoMaxCiphertextSizeVal()+1) - - err := validateCiphertextSize(ciphertext, w, errResp) - - if err == nil { - t.Error("validateCiphertextSize() expected error for oversized ciphertext") - } - - if w.Code != http.StatusBadRequest { - t.Errorf("validateCiphertextSize() status = %d, want %d", - w.Code, http.StatusBadRequest) - } -} - -func TestValidatePlaintextSize_ValidSize(t *testing.T) { - tests := []struct { - name string - size int - }{ - {"small", 100}, - {"medium", 1000}, - {"at max", env.CryptoMaxPlaintextSizeVal()}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "plaintext too large"} - plaintext := make([]byte, tt.size) - - err := validatePlaintextSize(plaintext, w, errResp) - - if err != nil { - t.Errorf("validatePlaintextSize() error = %v, want nil", err) - } - - if w.Code != http.StatusOK { - t.Errorf("validatePlaintextSize() status = %d, want %d", - w.Code, http.StatusOK) - } - }) - } -} - -func TestValidatePlaintextSize_TooLarge(t *testing.T) { - w := httptest.NewRecorder() - errResp := testErrorResponse{Err: "plaintext too large"} - plaintext := make([]byte, env.CryptoMaxPlaintextSizeVal()+1) - - err := validatePlaintextSize(plaintext, w, errResp) - - if err == nil { - t.Error("validatePlaintextSize() expected error for oversized plaintext") - } - - if w.Code != http.StatusBadRequest { - t.Errorf("validatePlaintextSize() status = %d, want %d", - w.Code, http.StatusBadRequest) - } -} - -func TestCipherConstants(t *testing.T) { - // Verify AES-GCM standard values - - //goland:noinspection GoBoolExpressions - if expectedNonceSize != 12 { - t.Errorf("expectedNonceSize = %d, want 12 (AES-GCM standard)", - expectedNonceSize) - } - - // maxPlaintextSize should be 16 bytes less than maxCiphertextSize - // to account for the AES-GCM authentication tag - if env.CryptoMaxPlaintextSizeVal() != env.CryptoMaxCiphertextSizeVal()-16 { - t.Errorf("maxPlaintextSize = %d, want %d (maxCiphertextSize - 16)", - env.CryptoMaxCiphertextSizeVal(), env.CryptoMaxCiphertextSizeVal()-16) - } - - // Verify version byte - if spikeCipherVersion != byte('1') { - t.Errorf("spikeCipherVersion = %v, want '1'", spikeCipherVersion) - } -} diff --git a/app/nexus/internal/route/operator/recover_intercept.go b/app/nexus/internal/route/operator/recover_intercept.go index c6524c67..8fd90d4d 100644 --- a/app/nexus/internal/route/operator/recover_intercept.go +++ b/app/nexus/internal/route/operator/recover_intercept.go @@ -42,6 +42,8 @@ import ( func guardRecoverRequest( _ reqres.RecoverRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + // No CheckAccess because this route is privileged and should not honor + // policy overrides. Match exact SPIFFE ID instead. return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRecover, reqres.RestoreResponse{}.Unauthorized(), w, r, ) diff --git a/app/nexus/internal/route/operator/restore_intercept.go b/app/nexus/internal/route/operator/restore_intercept.go index 18f9ce1b..1bcd80ec 100644 --- a/app/nexus/internal/route/operator/restore_intercept.go +++ b/app/nexus/internal/route/operator/restore_intercept.go @@ -14,6 +14,8 @@ import ( "github.com/spiffe/spike-sdk-go/spiffeid" ) +const minRequestId = 1 + // guardRestoreRequest validates a system restore request by performing // authentication, authorization, and input validation checks. // @@ -46,6 +48,8 @@ import ( func guardRestoreRequest( request reqres.RestoreRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + // No CheckAccess because this route is privileged and should not honor + // policy overrides. Match exact SPIFFE ID instead. err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRestore, reqres.RestoreResponse{}.Unauthorized(), w, r, ) @@ -53,8 +57,7 @@ func guardRestoreRequest( return err } - // TODO: magic number: 1 - if request.ID < 1 || request.ID > env.ShamirMaxShareCountVal() { + if request.ID < minRequestId || request.ID > env.ShamirMaxShareCountVal() { failErr := net.Fail( reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest, ) diff --git a/app/nexus/internal/route/secret/list_intercept.go b/app/nexus/internal/route/secret/list_intercept.go index 0fd0ac44..dd2adbcd 100644 --- a/app/nexus/internal/route/secret/list_intercept.go +++ b/app/nexus/internal/route/secret/list_intercept.go @@ -12,6 +12,7 @@ import ( apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -42,6 +43,15 @@ import ( func guardListSecretRequest( _ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForSecretList, // TODO: TO SDK. + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr + } + peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( r, w, reqres.SecretListResponse{}.Unauthorized(), ) diff --git a/go.mod b/go.mod index 85f8fb6c..09359c46 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.31 + github.com/spiffe/spike-sdk-go v0.17.32 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 244b6096..d64f549f 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,7 @@ github.com/spiffe/spike-sdk-go v0.17.29/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2N github.com/spiffe/spike-sdk-go v0.17.30/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/spiffe/spike-sdk-go v0.17.31 h1:wafqzzexieCzNCi39VFx1+qOxl8cRM52n1qdjcN7jOc= github.com/spiffe/spike-sdk-go v0.17.31/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.32/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 355f6589d84c1c6051e80f7c89f16b7c07022c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Fri, 16 Jan 2026 01:29:19 -0800 Subject: [PATCH 19/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code does not compile. Signed-off-by: Volkan Özçelik --- app/nexus/internal/route/base/guard_test.go | 1 - .../internal/route/secret/delete_intercept.go | 25 +- .../internal/route/secret/get_intercept.go | 25 +- app/nexus/internal/route/secret/guard.go | 73 ---- .../internal/route/secret/list_intercept.go | 34 +- .../route/secret/metadata_get_intercept.go | 55 +-- .../internal/route/secret/put_intercept.go | 71 +--- .../route/secret/undelete_intercept.go | 25 +- app/nexus/internal/state/base/policy.go | 12 +- docs-src/content/operations/multi-tenancy.md | 359 ++++++++++++++++++ .../templates/shortcodes/toc_operations.md | 2 + go.mod | 2 +- go.sum | 28 +- 13 files changed, 466 insertions(+), 246 deletions(-) delete mode 100644 app/nexus/internal/route/secret/guard.go create mode 100644 docs-src/content/operations/multi-tenancy.md diff --git a/app/nexus/internal/route/base/guard_test.go b/app/nexus/internal/route/base/guard_test.go index 61c42708..e86123cf 100644 --- a/app/nexus/internal/route/base/guard_test.go +++ b/app/nexus/internal/route/base/guard_test.go @@ -115,7 +115,6 @@ func checkDirectory(t *testing.T, dir string) []string { func isUtilityFile(name string) bool { utilityFiles := []string{ "errors.go", - "guard.go", "map.go", "config.go", "crypto.go", diff --git a/app/nexus/internal/route/secret/delete_intercept.go b/app/nexus/internal/route/secret/delete_intercept.go index 7495799e..20a293e5 100644 --- a/app/nexus/internal/route/secret/delete_intercept.go +++ b/app/nexus/internal/route/secret/delete_intercept.go @@ -7,9 +7,11 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) // guardDeleteSecretRequest validates a secret deletion request by performing @@ -37,11 +39,22 @@ import ( func guardDeleteSecretRequest( request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return guardSecretRequest( - request.Path, - []data.PolicyPermission{data.PermissionWrite}, - w, r, + if authErr := net.AuthorizeAndRespondOnFail( reqres.SecretDeleteResponse{}.Unauthorized(), - reqres.SecretDeleteResponse{}.BadRequest(), + func( + peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, + ) bool { + return predicate.AllowSPIFFEIDForSecretDelete( + peerSPIFFEID, request.Path, checkAccess, + ) + }, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr + } + + return net.RespondErrOnBadPath( + request.Path, reqres.SecretDeleteResponse{}.BadRequest(), w, ) } diff --git a/app/nexus/internal/route/secret/get_intercept.go b/app/nexus/internal/route/secret/get_intercept.go index 785f9460..a405e83d 100644 --- a/app/nexus/internal/route/secret/get_intercept.go +++ b/app/nexus/internal/route/secret/get_intercept.go @@ -7,9 +7,11 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) // guardGetSecretRequest validates a secret retrieval request by performing @@ -38,11 +40,22 @@ import ( func guardGetSecretRequest( request reqres.SecretGetRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return guardSecretRequest( - request.Path, - []data.PolicyPermission{data.PermissionRead}, - w, r, + if authErr := net.AuthorizeAndRespondOnFail( reqres.SecretGetResponse{}.Unauthorized(), - reqres.SecretGetResponse{}.BadRequest(), + func( + peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, + ) bool { + return predicate.AllowSPIFFEIDForSecretRead( + peerSPIFFEID, request.Path, checkAccess, + ) + }, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr + } + + return net.RespondErrOnBadPath( + request.Path, reqres.SecretGetResponse{}.BadRequest(), w, ) } diff --git a/app/nexus/internal/route/secret/guard.go b/app/nexus/internal/route/secret/guard.go deleted file mode 100644 index 5446c3ce..00000000 --- a/app/nexus/internal/route/secret/guard.go +++ /dev/null @@ -1,73 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package secret - -import ( - "net/http" - - "github.com/spiffe/spike-sdk-go/api/entity/data" - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/predicate" - - state "github.com/spiffe/spike/app/nexus/internal/state/base" -) - -// guardSecretRequest is a generic helper that validates secret requests by -// performing authentication, authorization, and path validation. It extracts -// the common validation pattern used across secret operations (get, put, -// delete, undelete, etc.). -// -// On failure, this function automatically writes the appropriate HTTP error -// response before returning the error. -// -// Type Parameters: -// - TUnauth: The response type for unauthorized access errors -// - TBadInput: The response type for invalid path errors -// -// Parameters: -// - path: The namespace path to validate and authorize -// - permissions: The required permissions for the operation -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the peer SPIFFE ID -// - unauthorizedResp: The error response to send if unauthorized -// - badInputResp: The error response to send if the path is invalid -// -// Returns: -// - *sdkErrors.SDKError: An error if authentication, authorization, or -// validation fails. Returns nil if all validations pass. -func guardSecretRequest[TUnauth, TBadInput any]( - path string, - permissions []data.PolicyPermission, - w http.ResponseWriter, - r *http.Request, - unauthorizedResp TUnauth, - badInputResp TBadInput, -) *sdkErrors.SDKError { - // Extract and validate peer SPIFFE ID - _, err := net.ExtractPeerSPIFFEIDAndRespondOnFail[TUnauth]( - w, r, unauthorizedResp, - ) - if alreadyResponded := err != nil; alreadyResponded { - return err - } - - // Check access permissions - authErr := net.RespondUnauthorizedOnPredicateFail( - func(peerSPIFFEID string) bool { - return predicate.AllowSPIFFEIDForPathAndPermissions( - peerSPIFFEID, path, permissions, state.CheckAccess, - ) - }, - reqres.PolicyDeleteResponse{}.Unauthorized(), w, r, - ) - if authErr != nil { - return authErr - } - - // Validate path format - return net.RespondErrOnBadPath(path, badInputResp, w) -} diff --git a/app/nexus/internal/route/secret/list_intercept.go b/app/nexus/internal/route/secret/list_intercept.go index dd2adbcd..ed6708fa 100644 --- a/app/nexus/internal/route/secret/list_intercept.go +++ b/app/nexus/internal/route/secret/list_intercept.go @@ -7,9 +7,7 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" @@ -43,36 +41,10 @@ import ( func guardListSecretRequest( _ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - if authErr := net.AuthorizeAndRespondOnFail( - reqres.CipherDecryptResponse{}.Unauthorized(), - predicate.AllowSPIFFEIDForSecretList, // TODO: TO SDK. + return net.AuthorizeAndRespondOnFail( + reqres.SecretListResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForSecretList, state.CheckAccess, w, r, - ); authErr != nil { - return authErr - } - - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDAndRespondOnFail( - r, w, reqres.SecretListResponse{}.Unauthorized(), - ) - if err != nil { - return err - } - - allowed := state.CheckAccess( - peerSPIFFEID.String(), apiAuth.PathSystemSecretAccess, - []data.PolicyPermission{data.PermissionList}, ) - if !allowed { - failErr := net.Fail( - reqres.SecretListResponse{}.Unauthorized(), w, - http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized - } - - return nil } diff --git a/app/nexus/internal/route/secret/metadata_get_intercept.go b/app/nexus/internal/route/secret/metadata_get_intercept.go index 6cf685c1..f5ef6317 100644 --- a/app/nexus/internal/route/secret/metadata_get_intercept.go +++ b/app/nexus/internal/route/secret/metadata_get_intercept.go @@ -7,12 +7,10 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" - + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -43,43 +41,22 @@ import ( func guardGetSecretMetadataRequest( request reqres.SecretMetadataRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.SecretMetadataResponse]( - r, w, reqres.SecretMetadataResponse{}.Unauthorized(), - ) - if alreadyResponded := err != nil; alreadyResponded { - return err - } - - path := request.Path - pathErr := validation.ValidatePath(path) - if pathErr != nil { - failErr := net.Fail( - reqres.SecretMetadataResponse{}.BadRequest(), w, - http.StatusBadRequest, - ) - pathErr.Msg = "invalid secret path: " + path - if failErr != nil { - return pathErr.Wrap(failErr) - } - return pathErr - } - - allowed := state.CheckAccess( - peerSPIFFEID.String(), path, - []data.PolicyPermission{data.PermissionRead}, - ) - if !allowed { - failErr := net.Fail( - reqres.SecretMetadataResponse{}.Unauthorized(), w, - http.StatusUnauthorized, - ) - authErr := sdkErrors.ErrAccessUnauthorized.Clone() - authErr.Msg = "unauthorized to read secret metadata for: " + path - if failErr != nil { - return authErr.Wrap(failErr) - } + if authErr := net.AuthorizeAndRespondOnFail( + reqres.SecretMetadataResponse{}.Unauthorized(), + func( + peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, + ) bool { + return predicate.AllowSPIFFEIDForSecretMetaDataRead( + peerSPIFFEID, request.Path, checkAccess, + ) + }, + state.CheckAccess, + w, r, + ); authErr != nil { return authErr } - return nil + return net.RespondErrOnBadPath( + request.Path, reqres.SecretMetadataResponse{}.BadRequest(), w, + ) } diff --git a/app/nexus/internal/route/secret/put_intercept.go b/app/nexus/internal/route/secret/put_intercept.go index 4be39c1a..29800211 100644 --- a/app/nexus/internal/route/secret/put_intercept.go +++ b/app/nexus/internal/route/secret/put_intercept.go @@ -7,12 +7,10 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/validation" - + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -46,59 +44,28 @@ import ( func guardSecretPutRequest( request reqres.SecretPutRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.SecretPutResponse]( - r, w, reqres.SecretPutResponse{}.Unauthorized(), - ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.SecretPutResponse{}.Unauthorized(), + func( + peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, + ) bool { + return predicate.AllowSPIFFEIDForSecretWrite( + peerSPIFFEID, request.Path, checkAccess, + ) + }, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr } - path := request.Path - - pathErr := validation.ValidatePath(path) - if invalidPath := pathErr != nil; invalidPath { - failErr := net.Fail( - reqres.SecretPutResponse{}.BadRequest(), w, - http.StatusBadRequest, - ) - pathErr.Msg = "invalid secret path: " + path - if failErr != nil { - return pathErr.Wrap(failErr) - } + if pathErr := net.RespondErrOnBadPath( + request.Path, reqres.SecretPutResponse{}.BadRequest(), w, + ); pathErr != nil { return pathErr } - values := request.Values - for k := range values { - nameErr := validation.ValidateName(k) - if nameErr != nil { - nameErr.Msg = "invalid key name: " + k - failErr := net.Fail( - reqres.SecretPutResponse{}.BadRequest(), w, - http.StatusBadRequest, - ) - if failErr != nil { - return nameErr.Wrap(failErr) - } - return nameErr - } - } - - allowed := state.CheckAccess( - peerSPIFFEID.String(), path, - []data.PolicyPermission{data.PermissionWrite}, + return net.RespondErrOnBadValues( + request.Values, reqres.SecretPutResponse{}.BadRequest(), w, ) - if !allowed { - failErr := net.Fail( - reqres.SecretPutResponse{}.Unauthorized(), w, - http.StatusUnauthorized, - ) - authErr := sdkErrors.ErrAccessUnauthorized.Clone() - authErr.Msg = "unauthorized to write secret: " + path - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return authErr - } - return nil } diff --git a/app/nexus/internal/route/secret/undelete_intercept.go b/app/nexus/internal/route/secret/undelete_intercept.go index 501b6976..a901a181 100644 --- a/app/nexus/internal/route/secret/undelete_intercept.go +++ b/app/nexus/internal/route/secret/undelete_intercept.go @@ -7,9 +7,11 @@ package secret import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) // guardSecretUndeleteRequest validates a secret restoration request by @@ -41,11 +43,22 @@ import ( func guardSecretUndeleteRequest( request reqres.SecretUndeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return guardSecretRequest( - request.Path, - []data.PolicyPermission{data.PermissionWrite}, - w, r, + if authErr := net.AuthorizeAndRespondOnFail( reqres.SecretUndeleteResponse{}.Unauthorized(), - reqres.SecretUndeleteResponse{}.BadRequest(), + func( + peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, + ) bool { + return predicate.AllowSPIFFEIDForSecretUndelete( + peerSPIFFEID, request.Path, checkAccess, + ) + }, + state.CheckAccess, + w, r, + ); authErr != nil { + return authErr + } + + return net.RespondErrOnBadPath( + request.Path, reqres.SecretUndeleteResponse{}.BadRequest(), w, ) } diff --git a/app/nexus/internal/state/base/policy.go b/app/nexus/internal/state/base/policy.go index 9a4e3ad7..85a63069 100644 --- a/app/nexus/internal/state/base/policy.go +++ b/app/nexus/internal/state/base/policy.go @@ -13,8 +13,6 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/data" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/spiffeid" - "github.com/spiffe/spike/app/nexus/internal/state/persist" ) @@ -50,11 +48,11 @@ func CheckAccess( ) bool { const fName = "CheckAccess" - // SPIKE Pilot is a system workload; no policy check needed. - if spiffeid.IsPilotOperator(peerSPIFFEID) { - return true - } - + //// SPIKE Pilot is a system workload; no policy check needed. + //if spiffeid.IsPilotOperator(peerSPIFFEID) { + // return true + //} + // policies, err := ListPolicies() if err != nil { log.WarnErr(fName, *sdkErrors.ErrEntityLoadFailed.Clone()) diff --git a/docs-src/content/operations/multi-tenancy.md b/docs-src/content/operations/multi-tenancy.md new file mode 100644 index 00000000..42ef8093 --- /dev/null +++ b/docs-src/content/operations/multi-tenancy.md @@ -0,0 +1,359 @@ ++++ +# \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +# \\\\\ Copyright 2024-present SPIKE contributors. +# \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +title = "Multi-Tenancy Deployment Patterns" +weight = 6 +sort_by = "weight" ++++ + +# Multi-Tenancy Deployment Patterns + +This guide describes strategies for deploying **SPIKE** in multi-tenant +environments where multiple organizational units, teams, or customers need +isolated secrets management. + +## Understanding SPIKE's Single-Tenant Model + +By default, SPIKE operates with a single-tenant model: + +* **SPIKE Pilot** has a system SPIFFE ID that grants unrestricted access to all + secrets and policies +* The operator (*anyone with access to SPIKE Pilot*) has full visibility across + the entire secret store +* Policies control workload access but do not restrict administrative access + +This model works well when: + +- A single team or organization manages all secrets +- The operator is trusted with visibility into all data +- There is no requirement for administrative isolation between groups + +## When You Need Multi-Tenancy + +Consider multi-tenancy when: + +- **Organizational boundaries**: Different business units should not see each + other's secrets +- **Customer isolation**: You are building a platform where customers expect + data isolation +- **Compliance requirements**: Regulations mandate administrative separation +- **Blast radius reduction**: You want to limit the impact of credential + compromise +- **Delegated administration**: Tenant admins should manage their own scope + without central operator involvement + +## Current Multi-Tenancy Options + +SPIKE currently supports two approaches to multi-tenancy, each with different +trade-offs. + +### Option 1: Separate SPIKE Deployments (*Strong Isolation*) + +Deploy independent SPIKE instances for each tenant, typically in separate +Kubernetes namespaces or on separate infrastructure. + +``` +Tenant: PepsiCola Tenant: CocaCola +namespace: pepsi-secrets namespace: coca-secrets +├── spike-nexus ├── spike-nexus +├── spike-keeper (x3) ├── spike-keeper (x3) +├── spike-pilot ├── spike-pilot +└── spike-bootstrap └── spike-bootstrap +``` + +#### Kubernetes Example + +```yaml +# pepsi-secrets namespace +apiVersion: v1 +kind: Namespace +metadata: + name: pepsi-secrets + labels: + tenant: pepsi +--- +# Deploy SPIKE components in this namespace +# (Helm chart or manifests with namespace: pepsi-secrets) +``` + +```yaml +# coca-secrets namespace +apiVersion: v1 +kind: Namespace +metadata: + name: coca-secrets + labels: + tenant: coca +--- +# Deploy SPIKE components in this namespace +# (Helm chart or manifests with namespace: coca-secrets) +``` + +#### SPIRE Configuration + +Each tenant deployment requires its own SPIRE registration entries. You can +use the same SPIRE Server with different SPIFFE ID paths per tenant: + +```bash +# Pepsi tenant SPIKE components +spire-server entry create \ + -spiffeID spiffe://example.org/tenant/pepsi/spike/nexus \ + -parentID spiffe://example.org/spire/agent/k8s/pepsi-secrets \ + -selector k8s:ns:pepsi-secrets \ + -selector k8s:sa:spike-nexus + +spire-server entry create \ + -spiffeID spiffe://example.org/tenant/pepsi/spike/pilot/role/superuser \ + -parentID spiffe://example.org/spire/agent/k8s/pepsi-secrets \ + -selector k8s:ns:pepsi-secrets \ + -selector k8s:sa:spike-pilot + +# Coca tenant SPIKE components +spire-server entry create \ + -spiffeID spiffe://example.org/tenant/coca/spike/nexus \ + -parentID spiffe://example.org/spire/agent/k8s/coca-secrets \ + -selector k8s:ns:coca-secrets \ + -selector k8s:sa:spike-nexus + +spire-server entry create \ + -spiffeID spiffe://example.org/tenant/coca/spike/pilot/role/superuser \ + -parentID spiffe://example.org/spire/agent/k8s/coca-secrets \ + -selector k8s:ns:coca-secrets \ + -selector k8s:sa:spike-pilot +``` + +#### Environment Configuration + +Each tenant's SPIKE components need their own trust root configuration: + +```bash +# Pepsi tenant +export SPIKE_TRUST_ROOT_NEXUS="tenant/pepsi" +export SPIKE_TRUST_ROOT_PILOT="tenant/pepsi" +export SPIKE_TRUST_ROOT_KEEPER="tenant/pepsi" +export SPIKE_TRUST_ROOT_BOOTSTRAP="tenant/pepsi" + +# Coca tenant +export SPIKE_TRUST_ROOT_NEXUS="tenant/coca" +export SPIKE_TRUST_ROOT_PILOT="tenant/coca" +export SPIKE_TRUST_ROOT_KEEPER="tenant/coca" +export SPIKE_TRUST_ROOT_BOOTSTRAP="tenant/coca" +``` + +#### Characteristics + +| Aspect | Description | +|----------------------|-------------------------------------| +| Isolation Level | Strong (complete separation) | +| Operational Overhead | High (N deployments to manage) | +| Resource Usage | Higher (duplicate components) | +| Cross-Tenant Access | Impossible by design | +| Central Management | None (each tenant is independent) | +| Blast Radius | Limited to single tenant | + +#### When to Use + +* Tenants are external customers or competitors +* Regulatory requirements mandate complete isolation +* Tenants have different compliance requirements + (*e.g., one needs FIPS, another does not*) +* You need different availability or backup policies per tenant +* Maximum security is more important than operational efficiency + +#### Best Practices + +1. **Use GitOps**: Template your SPIKE deployment manifests to reduce + duplication +2. **Centralize SPIRE Server**: You can use a single SPIRE Server with + per-tenant SPIFFE ID paths +3. **Namespace isolation**: Use Kubernetes NetworkPolicies to prevent + cross-namespace communication +4. **Separate storage**: Each SPIKE Nexus should have its own database or + storage path +5. **Monitoring**: Aggregate metrics and logs centrally with tenant labels + +### Option 2: Path-Based Tenant Isolation (*Soft Isolation*) + +Use a single **SPIKE** deployment with path conventions and policies to isolate +tenant data. The central operator retains visibility across all tenants. + +``` +Single SPIKE Deployment +├── spike-nexus +├── spike-keeper (x3) +├── spike-pilot <-- Central operator, sees all tenants +└── spike-bootstrap + +Secret Paths: +├── tenants/pepsi/db/credentials +├── tenants/pepsi/api/keys +├── tenants/coca/db/credentials +└── tenants/coca/api/keys +``` + +#### Policy Configuration + +Create policies that restrict each tenant's workloads to their own paths: + +```bash +# Pepsi workloads can only access pepsi secrets +spike policy create --name=pepsi-workload-read \ + --path-pattern="^tenants/pepsi/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/pepsi/workload/.*$" \ + --permissions="read" + +spike policy create --name=pepsi-workload-write \ + --path-pattern="^tenants/pepsi/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/pepsi/workload/.*$" \ + --permissions="write" + +# Coca workloads can only access coca secrets +spike policy create --name=coca-workload-read \ + --path-pattern="^tenants/coca/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/coca/workload/.*$" \ + --permissions="read" + +spike policy create --name=coca-workload-write \ + --path-pattern="^tenants/coca/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/coca/workload/.*$" \ + --permissions="write" +``` + +#### Tenant Admin Workloads + +You can create "tenant admin" workloads that have broader permissions within +their tenant's scope: + +```bash +# Pepsi admin workload can manage all pepsi secrets +spike policy create --name=pepsi-admin \ + --path-pattern="^tenants/pepsi/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/pepsi/admin$" \ + --permissions="super" + +# Coca admin workload can manage all coca secrets +spike policy create --name=coca-admin \ + --path-pattern="^tenants/coca/.*$" \ + --spiffeid-pattern="^spiffe://example\.org/tenant/coca/admin$" \ + --permissions="super" +``` + +**Important**: These tenant admin workloads: + +- Can read/write/list secrets within their tenant's path +- Cannot see secrets outside their tenant's path +- Cannot create or modify policies (*that requires SPIKE Pilot access*) +- Cannot perform recovery/restore operations + +#### Characteristics + +| Aspect | Description | +|----------------------|----------------------------------------------| +| Isolation Level | Weak (workloads isolated, operator sees all) | +| Operational Overhead | Low (single deployment) | +| Resource Usage | Lower (shared components) | +| Cross-Tenant Access | Prevented by policy (for workloads) | +| Central Management | Yes (single Pilot manages all) | +| Blast Radius | Full system if Pilot is compromised | + +#### When to Use + +* Tenants are internal teams within the same organization +* A trusted central operator is acceptable +* You want simpler operations with a single deployment +* Workload isolation is sufficient (*admin visibility is acceptable*) +* You need cross-tenant visibility for auditing or compliance + +#### Best Practices + +1. **Consistent path conventions**: Establish and enforce a standard path + structure (e.g., `tenants/{tenant-name}/{category}/{secret-name}`) +2. **Policy naming conventions**: Use clear names that indicate tenant scope + (e.g., `{tenant}-{role}-{permission}`) +3. **Regular policy audits**: Review policies to ensure no accidental + cross-tenant access +4. **SPIFFE ID conventions**: Structure workload SPIFFE IDs to include tenant + information for easier policy matching +5. **Documentation**: Document the path structure and policy model for all + tenant administrators + +#### Limitations + +- **No administrative isolation**: The central Pilot operator can see all + tenants' secrets +- **Policy misconfiguration risk**: An incorrect regex pattern could leak + secrets across tenants +- **No delegated policy management**: Tenants cannot create their own policies +- **Single point of trust**: Pilot credential compromise affects all tenants + +## Comparison Matrix + +| Capability | Separate Deployments | Path-Based Isolation | +|------------------------------|-------------------------|----------------------| +| Workload isolation | Yes | Yes | +| Administrative isolation | Yes | No | +| Delegated policy management | Yes (per-tenant Pilot) | No | +| Cross-tenant visibility | No | Yes (for operator) | +| Resource efficiency | Lower | Higher | +| Operational complexity | Higher | Lower | +| Blast radius | Per-tenant | Full system | +| Compliance suitability | Higher | Lower | + +## Future: Scoped Pilot Instances + +A future enhancement +**Scoped Pilot Instances** that would provide administrative isolation within +a single SPIKE deployment: + +``` +# Future capability (not yet implemented) +spiffe://example.org/spike/pilot/scope/tenants/pepsi +spiffe://example.org/spike/pilot/scope/tenants/coca +``` + +(see +[EPIC: Scoped SPIKE Pilot Instances for Multi-Tenancy](https://github.com/spiffe/spike/issues/281)) + +Scoped Pilots would: + +- Only see secrets and policies within their designated scope +- Only create policies for paths within their scope +- Provide delegated administration without full deployment separation +- Maintain a single Nexus/Keeper infrastructure + +This would offer a middle ground between the two current options, providing +administrative isolation with lower operational overhead than separate +deployments. + +## Choosing an Approach + +Use this decision tree to select the right approach: + +``` +Do tenants require COMPLETE administrative isolation? +| ++-- YES --> Are tenants external customers or competitors? +| | +| +-- YES --> Separate Deployments (Option 1) +| | +| +-- NO --> Wait for Scoped Pilots (ADR-0033) +| or use Separate Deployments +| ++-- NO --> Is a trusted central operator acceptable? + | + +-- YES --> Path-Based Isolation (Option 2) + | + +-- NO --> Separate Deployments (Option 1) +``` + +

 

+ +---- + +{{ toc_operations() }} + +---- + +{{ toc_top() }} diff --git a/docs-src/templates/shortcodes/toc_operations.md b/docs-src/templates/shortcodes/toc_operations.md index e29d49d4..ffcece48 100644 --- a/docs-src/templates/shortcodes/toc_operations.md +++ b/docs-src/templates/shortcodes/toc_operations.md @@ -7,4 +7,6 @@ * [SPIKE Cross-Platform Build](@/operations/build.md) * [SPIKE Production Setup](@/operations/production.md) * [SPIKE Recovery Procedures](@/operations/recovery.md) +* [SPIKE Backup and Restore](@/operations/backup.md) +* [Multi-Tenancy Deployment Patterns](@/operations/multi-tenancy.md) * [SPIKE Release Management](@/operations/release.md) \ No newline at end of file diff --git a/go.mod b/go.mod index 09359c46..4762d2ea 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.32 + github.com/spiffe/spike-sdk-go v0.17.36 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index d64f549f..e7d414d4 100644 --- a/go.sum +++ b/go.sum @@ -126,16 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.17.25 h1:cqEnl8TviY03y7WZX7zqo5aHnAzjN+4t7Sbpz5hK3ZA= -github.com/spiffe/spike-sdk-go v0.17.25/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.26/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.27/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.28/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.29/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.30/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.31 h1:wafqzzexieCzNCi39VFx1+qOxl8cRM52n1qdjcN7jOc= -github.com/spiffe/spike-sdk-go v0.17.31/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.17.32/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.17.36 h1:Nx4YhosaMnbw0JcJj+aqnr45ySaIMmzhAnFqiZGvAwM= +github.com/spiffe/spike-sdk-go v0.17.36/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -163,17 +155,14 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -192,17 +181,11 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -211,9 +194,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= @@ -221,8 +203,6 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= From c20e93455af1d050d1d929aecd03e4304ded9760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Fri, 16 Jan 2026 07:17:31 -0800 Subject: [PATCH 20/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code compiles. Signed-off-by: Volkan Özçelik --- .../route/cipher/encrypt_intercept.go | 36 +++++++++---------- app/nexus/internal/route/operator/restore.go | 5 +-- .../internal/route/operator/test_helper.go | 4 +-- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index 2ddbd515..c2f23ca1 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -7,33 +7,29 @@ package cipher import ( "net/http" - "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - apiAuth "github.com/spiffe/spike-sdk-go/config/auth" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" - sdkSpiffeid "github.com/spiffe/spike-sdk-go/spiffeid" - state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -func spiffeidAllowedForEncryptCipher(spiffeid string) bool { - // Lite Workloads are always allowed: - allowed := false - if sdkSpiffeid.IsLiteWorkload(spiffeid) { - allowed = true - } - // If not, do a policy check to determine if the request is allowed: - if !allowed { - allowed = state.CheckAccess( - spiffeid, - apiAuth.PathSystemCipherEncrypt, - []data.PolicyPermission{data.PermissionExecute}, - ) - } - return allowed -} +//func spiffeidAllowedForEncryptCipher(spiffeid string) bool { +// // Lite Workloads are always allowed: +// allowed := false +// if sdkSpiffeid.IsLiteWorkload(spiffeid) { +// allowed = true +// } +// // If not, do a policy check to determine if the request is allowed: +// if !allowed { +// allowed = state.CheckAccess( +// spiffeid, +// apiAuth.PathSystemCipherExecute, +// []data.PolicyPermission{data.PermissionExecute}, +// ) +// } +// return allowed +//} // guardCipherEncryptRequest validates a cipher encryption request by // performing authentication, authorization, and request field validation. diff --git a/app/nexus/internal/route/operator/restore.go b/app/nexus/internal/route/operator/restore.go index 74d42f78..2b282021 100644 --- a/app/nexus/internal/route/operator/restore.go +++ b/app/nexus/internal/route/operator/restore.go @@ -11,6 +11,7 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" "github.com/spiffe/spike-sdk-go/config/env" + "github.com/spiffe/spike-sdk-go/crypto" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/log" "github.com/spiffe/spike-sdk-go/net" @@ -21,7 +22,7 @@ import ( ) var ( - shards []recovery.ShamirShard + shards []crypto.ShamirShard shardsMutex sync.RWMutex ) @@ -115,7 +116,7 @@ func RouteRestore( ) } - shards = append(shards, recovery.ShamirShard{ + shards = append(shards, crypto.ShamirShard{ ID: uint64(request.ID), Value: request.Shard, }) diff --git a/app/nexus/internal/route/operator/test_helper.go b/app/nexus/internal/route/operator/test_helper.go index 737d4429..ea154b71 100644 --- a/app/nexus/internal/route/operator/test_helper.go +++ b/app/nexus/internal/route/operator/test_helper.go @@ -6,8 +6,6 @@ package operator import ( "github.com/spiffe/spike-sdk-go/crypto" - - "github.com/spiffe/spike/app/nexus/internal/initialization/recovery" ) // Helper functions @@ -15,7 +13,7 @@ import ( func resetShards() { shardsMutex.Lock() defer shardsMutex.Unlock() - shards = []recovery.ShamirShard{} + shards = []crypto.ShamirShard{} } func createTestShardValue(id int) *[crypto.AES256KeySize]byte { From de67707ddbffb2c5b21c5760d53b6936a9abb834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Sat, 17 Jan 2026 12:49:30 -0800 Subject: [PATCH 21/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/bootstrap/cmd/main.go | 12 +- app/bootstrap/internal/lifecycle/lifecycle.go | 18 +- app/keeper/cmd/main.go | 4 +- app/keeper/internal/route/base/route.go | 3 +- .../route/store/contribute_intercept.go | 6 +- .../internal/route/store/shard_intercept.go | 6 +- app/keeper/internal/state/doc.go | 15 ++ app/keeper/internal/state/shard.go | 3 - .../internal/initialization/initialization.go | 9 +- app/nexus/internal/route/acl/policy/delete.go | 11 +- .../route/acl/policy/delete_intercept.go | 2 +- app/nexus/internal/route/acl/policy/get.go | 11 +- .../route/acl/policy/get_intercept.go | 3 +- app/nexus/internal/route/acl/policy/list.go | 6 +- .../route/acl/policy/list_intercept.go | 2 +- .../internal/route/acl/policy/predicate.go | 12 - app/nexus/internal/route/acl/policy/put.go | 10 +- .../route/acl/policy/put_intercept.go | 2 +- app/nexus/internal/route/base/impl.go | 2 +- app/nexus/internal/route/base/route.go | 10 +- .../route/bootstrap/verify_intercept.go | 13 +- .../route/cipher/decrypt_intercept.go | 2 +- .../route/cipher/encrypt_intercept.go | 4 +- app/nexus/internal/route/cipher/handle.go | 8 +- .../route/operator/recover_intercept.go | 6 +- .../route/operator/restore_intercept.go | 15 +- .../internal/route/secret/delete_intercept.go | 2 +- .../internal/route/secret/get_intercept.go | 2 +- .../internal/route/secret/list_intercept.go | 2 +- .../route/secret/metadata_get_intercept.go | 2 +- .../internal/route/secret/put_intercept.go | 2 +- .../route/secret/undelete_intercept.go | 2 +- app/nexus/internal/state/base/doc.go | 2 +- app/nexus/internal/state/base/policy.go | 13 +- app/nexus/internal/state/base/policy_test.go | 12 +- go.mod | 2 +- go.sum | 5 +- internal/net/doc.go | 36 --- internal/net/factory.go | 41 --- internal/net/handle.go | 76 ------ internal/net/response.go | 72 ------ internal/net/response_test.go | 242 ------------------ 42 files changed, 109 insertions(+), 599 deletions(-) create mode 100644 app/keeper/internal/state/doc.go delete mode 100644 app/nexus/internal/route/acl/policy/predicate.go delete mode 100644 internal/net/doc.go delete mode 100644 internal/net/factory.go delete mode 100644 internal/net/handle.go delete mode 100644 internal/net/response.go delete mode 100644 internal/net/response_test.go diff --git a/app/bootstrap/cmd/main.go b/app/bootstrap/cmd/main.go index 2903abcb..2b1b4f57 100644 --- a/app/bootstrap/cmd/main.go +++ b/app/bootstrap/cmd/main.go @@ -26,11 +26,7 @@ import ( const appName = "SPIKE Bootstrap" func main() { - log.Info( - appName, - "message", "starting", - "version", config.BootstrapVersion, - ) + log.Info(appName, "message", "starting", "version", config.BootstrapVersion) // Hard timeout for the entire bootstrap process. // A value of 0 means no timeout (infinite). @@ -72,11 +68,7 @@ func main() { return } - log.Info( - appName, - "message", "FIPS 140.3 Status", - "enabled", fips140.Enabled(), - ) + log.Info(appName, "message", "FIPS Status", "enabled", fips140.Enabled()) // Panics if it cannot acquire the source. src := net.AcquireSource() diff --git a/app/bootstrap/internal/lifecycle/lifecycle.go b/app/bootstrap/internal/lifecycle/lifecycle.go index e1466881..ebeb47c0 100644 --- a/app/bootstrap/internal/lifecycle/lifecycle.go +++ b/app/bootstrap/internal/lifecycle/lifecycle.go @@ -49,19 +49,13 @@ func ShouldBootstrap() bool { // Memory backend doesn't need bootstrap. if env.BackendStoreTypeVal() == env.Memory { - log.Info( - fName, - "message", "skipping bootstrap for in-memory backend", - ) + log.Info(fName, "message", "skipping bootstrap for in-memory backend") return false } // Lite backend doesn't need bootstrap. if env.BackendStoreTypeVal() == env.Lite { - log.Info( - fName, - "message", "skipping bootstrap for lite backend", - ) + log.Info(fName, "message", "skipping bootstrap for lite backend") return false } @@ -95,7 +89,7 @@ func ShouldBootstrap() bool { return false } - // We're in Kubernetes---check the ConfigMap + // We're in Kubernetes: Check the ConfigMap clientset, clientErr := kubernetes.NewForConfig(cfg) if clientErr != nil { failErr := sdkErrors.ErrK8sClientFailed.Clone() @@ -120,7 +114,7 @@ func ShouldBootstrap() bool { ) if getErr != nil { failErr := sdkErrors.ErrK8sReconciliationFailed.Wrap(getErr) - // ConfigMap doesn't exist or can't read it - proceed with bootstrap + // ConfigMap doesn't exist or can't read it: Proceed with bootstrap failErr.Msg = "failed to get ConfigMap: proceeding with bootstrap" log.WarnErr(fName, *failErr) return true @@ -142,10 +136,12 @@ func ShouldBootstrap() bool { keyBootstrapCompletedByPod, completedByPod, "reason", reason, ) + + // Bootstrap is complete: Skip further bootstraps return false } - // Bootstrap is not completed: proceed with bootstrap + // Bootstrap is not completed: Proceed with bootstrap return true } diff --git a/app/keeper/cmd/main.go b/app/keeper/cmd/main.go index e0d24efd..a825cec0 100644 --- a/app/keeper/cmd/main.go +++ b/app/keeper/cmd/main.go @@ -50,9 +50,7 @@ func main() { } log.Info( - appName, - "message", "started service", - "version", config.KeeperVersion, + appName, "message", "started service", "version", config.KeeperVersion, ) // Serve the app. diff --git a/app/keeper/internal/route/base/route.go b/app/keeper/internal/route/base/route.go index 1117e32f..cb6e4879 100644 --- a/app/keeper/internal/route/base/route.go +++ b/app/keeper/internal/route/base/route.go @@ -16,8 +16,9 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/journal" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike/app/keeper/internal/route/store" - "github.com/spiffe/spike/internal/net" ) // Route handles all incoming HTTP requests by dynamically selecting and diff --git a/app/keeper/internal/route/store/contribute_intercept.go b/app/keeper/internal/route/store/contribute_intercept.go index 94d94e8a..f7c0cf1a 100644 --- a/app/keeper/internal/route/store/contribute_intercept.go +++ b/app/keeper/internal/route/store/contribute_intercept.go @@ -31,7 +31,9 @@ import ( func guardShardPutRequest( _ reqres.ShardPutRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.RespondUnauthorizedOnPredicateFail(spiffeid.PeerCanTalkToKeeper, - reqres.ShardPutResponse{}.Unauthorized(), w, r, + return net.AuthorizeAndRespondOnFailNoPolicy( + reqres.PolicyPutResponse{}.Unauthorized(), + spiffeid.PeerCanTalkToKeeper, + w, r, ) } diff --git a/app/keeper/internal/route/store/shard_intercept.go b/app/keeper/internal/route/store/shard_intercept.go index 96ef8e91..2eb2259f 100644 --- a/app/keeper/internal/route/store/shard_intercept.go +++ b/app/keeper/internal/route/store/shard_intercept.go @@ -31,7 +31,9 @@ import ( func guardShardGetRequest( _ reqres.ShardGetRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsNexus, - reqres.ShardGetResponse{}.Unauthorized(), w, r, + return net.AuthorizeAndRespondOnFailNoPolicy( + reqres.ShardGetResponse{}.Unauthorized(), + spiffeid.IsNexus, + w, r, ) } diff --git a/app/keeper/internal/state/doc.go b/app/keeper/internal/state/doc.go new file mode 100644 index 00000000..994a265e --- /dev/null +++ b/app/keeper/internal/state/doc.go @@ -0,0 +1,15 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +// Package state manages the Shamir secret share for SPIKE Keeper. +// +// This package provides thread-safe storage and access to a single shard of +// the root key. SPIKE Nexus distributes shards to multiple Keeper instances +// using Shamir's Secret Sharing scheme. When Nexus needs to recover its root +// key, it collects shards from the required number of Keepers and reconstructs +// the original secret. +// +// The package ensures thread safety through a read-write mutex, allowing +// concurrent reads while serializing writes. +package state diff --git a/app/keeper/internal/state/shard.go b/app/keeper/internal/state/shard.go index 22893841..050ac188 100644 --- a/app/keeper/internal/state/shard.go +++ b/app/keeper/internal/state/shard.go @@ -2,9 +2,6 @@ // \\\\\ Copyright 2024-present SPIKE contributors. // \\\\\\\ SPDX-License-Identifier: Apache-2.0 -// Package state provides thread-safe utilities for securely managing -// and accessing a global shard value. It ensures consistent access -// and updates to the shard using synchronization primitives. package state import ( diff --git a/app/nexus/internal/initialization/initialization.go b/app/nexus/internal/initialization/initialization.go index 0c09e193..10c16b96 100644 --- a/app/nexus/internal/initialization/initialization.go +++ b/app/nexus/internal/initialization/initialization.go @@ -44,10 +44,8 @@ import ( func Initialize(source *workloadapi.X509Source) { const fName = "Initialize" - requireBackingStoreToBootstrap := env.BackendStoreTypeVal() == env.Sqlite || - env.BackendStoreTypeVal() == env.Lite - - if requireBackingStoreToBootstrap { + if requireBackingStoreToBootstrap := env.BackendStoreTypeVal() == env.Sqlite || + env.BackendStoreTypeVal() == env.Lite; requireBackingStoreToBootstrap { // Initialize the backing store from SPIKE Keeper instances. // This is only required when the SPIKE Nexus needs bootstrapping. // For modes where bootstrapping is not required (such as in-memory mode), @@ -78,8 +76,7 @@ func Initialize(source *workloadapi.X509Source) { // Better to crash, since this is likely a configuration failure. log.FatalLn( fName, - "message", - "invalid backend store type", + "message", "invalid backend store type", "type", env.BackendStoreTypeVal(), "valid_types", "sqlite, lite, memory", ) diff --git a/app/nexus/internal/route/acl/policy/delete.go b/app/nexus/internal/route/acl/policy/delete.go index 3b22919f..1371f41d 100644 --- a/app/nexus/internal/route/acl/policy/delete.go +++ b/app/nexus/internal/route/acl/policy/delete.go @@ -71,19 +71,16 @@ func RouteDeletePolicy( journal.AuditRequest(fName, r, audit, journal.AuditDelete) - request, err := net.ReadParseAndGuard[ + request, guardErr := net.ReadParseAndGuard[ reqres.PolicyDeleteRequest, reqres.PolicyDeleteResponse, ]( w, r, reqres.PolicyDeleteResponse{}.BadRequest(), guardPolicyDeleteRequest, ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if alreadyResponded := guardErr != nil; alreadyResponded { + return guardErr } - policyID := request.ID - - deleteErr := state.DeletePolicy(policyID) - if deleteErr != nil { + if deleteErr := state.DeletePolicy(request.ID); deleteErr != nil { return net.RespondWithHTTPError(deleteErr, w, reqres.PolicyDeleteResponse{}) } diff --git a/app/nexus/internal/route/acl/policy/delete_intercept.go b/app/nexus/internal/route/acl/policy/delete_intercept.go index 668b764b..c77a5f20 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -41,7 +41,7 @@ func guardPolicyDeleteRequest( if authErr := net.AuthorizeAndRespondOnFail( reqres.PolicyDeleteResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForPolicyDelete, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/acl/policy/get.go b/app/nexus/internal/route/acl/policy/get.go index abce19be..c82d3393 100644 --- a/app/nexus/internal/route/acl/policy/get.go +++ b/app/nexus/internal/route/acl/policy/get.go @@ -85,17 +85,14 @@ func RouteGetPolicy( journal.AuditRequest(fName, r, audit, journal.AuditRead) - request, err := net.ReadParseAndGuard[ - reqres.PolicyReadRequest, reqres.PolicyReadResponse]( + request, guardErr := net.ReadParseAndGuard( w, r, reqres.PolicyReadResponse{}.BadRequest(), guardPolicyReadRequest, ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if alreadyResponded := guardErr != nil; alreadyResponded { + return guardErr } - policyID := request.ID - - policy, policyErr := state.GetPolicy(policyID) + policy, policyErr := state.GetPolicy(request.ID) if policyErr != nil { return net.RespondWithHTTPError(policyErr, w, reqres.PolicyReadResponse{}) } diff --git a/app/nexus/internal/route/acl/policy/get_intercept.go b/app/nexus/internal/route/acl/policy/get_intercept.go index 6a9017d1..1e958389 100644 --- a/app/nexus/internal/route/acl/policy/get_intercept.go +++ b/app/nexus/internal/route/acl/policy/get_intercept.go @@ -11,6 +11,7 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -40,7 +41,7 @@ func guardPolicyReadRequest( if authErr := net.AuthorizeAndRespondOnFail( reqres.PolicyReadResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForPolicyRead, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/acl/policy/list.go b/app/nexus/internal/route/acl/policy/list.go index f87e8ed0..5e805c2c 100644 --- a/app/nexus/internal/route/acl/policy/list.go +++ b/app/nexus/internal/route/acl/policy/list.go @@ -61,12 +61,12 @@ func RouteListPolicies( journal.AuditRequest(fName, r, audit, journal.AuditList) - request, err := net.ReadParseAndGuard[ + request, guardErr := net.ReadParseAndGuard[ reqres.PolicyListRequest, reqres.PolicyListResponse]( w, r, reqres.PolicyListResponse{}.BadRequest(), guardListPolicyRequest, ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if alreadyResponded := guardErr != nil; alreadyResponded { + return guardErr } var policies []data.Policy diff --git a/app/nexus/internal/route/acl/policy/list_intercept.go b/app/nexus/internal/route/acl/policy/list_intercept.go index fae9b44f..c5d761a6 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -40,7 +40,7 @@ func guardListPolicyRequest( return net.AuthorizeAndRespondOnFail( reqres.PolicyListResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForPolicyList, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ) } diff --git a/app/nexus/internal/route/acl/policy/predicate.go b/app/nexus/internal/route/acl/policy/predicate.go deleted file mode 100644 index c47aa08d..00000000 --- a/app/nexus/internal/route/acl/policy/predicate.go +++ /dev/null @@ -1,12 +0,0 @@ -package policy - -// -//type PolicyAccessChecker func(peerSPIFFEID string, path string, perms []data.PolicyPermission) bool -// -//func spiffeidAllowedForPolicyDelete(peerSPIFFEID string, checkAccess PolicyAccessChecker) bool { -// return checkAccess( -// peerSPIFFEID, -// cfg.PathSystemPolicyAccess, -// []data.PolicyPermission{data.PermissionWrite}, -// ) -//} diff --git a/app/nexus/internal/route/acl/policy/put.go b/app/nexus/internal/route/acl/policy/put.go index 0ae06c5e..f43fbb40 100644 --- a/app/nexus/internal/route/acl/policy/put.go +++ b/app/nexus/internal/route/acl/policy/put.go @@ -10,9 +10,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -68,13 +68,11 @@ func RoutePutPolicy( journal.AuditRequest(fName, r, audit, journal.AuditCreate) - request, err := net.ReadParseAndGuard[ - reqres.PolicyPutRequest, reqres.PolicyPutResponse, - ]( + request, guardErr := net.ReadParseAndGuard( w, r, reqres.PolicyPutResponse{}.BadRequest(), guardPolicyPutRequest, ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if alreadyResponded := guardErr != nil; alreadyResponded { + return guardErr } policy, upsertErr := state.UpsertPolicy(data.Policy{ diff --git a/app/nexus/internal/route/acl/policy/put_intercept.go b/app/nexus/internal/route/acl/policy/put_intercept.go index 41cbca64..aa14ea86 100644 --- a/app/nexus/internal/route/acl/policy/put_intercept.go +++ b/app/nexus/internal/route/acl/policy/put_intercept.go @@ -44,7 +44,7 @@ func guardPolicyPutRequest( if authErr := net.AuthorizeAndRespondOnFail( reqres.PolicyPutResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForPolicyWrite, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/base/impl.go b/app/nexus/internal/route/base/impl.go index c5c88f59..3f6239f4 100644 --- a/app/nexus/internal/route/base/impl.go +++ b/app/nexus/internal/route/base/impl.go @@ -6,13 +6,13 @@ package base import ( "github.com/spiffe/spike-sdk-go/api/url" + "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike/app/nexus/internal/route/acl/policy" "github.com/spiffe/spike/app/nexus/internal/route/bootstrap" "github.com/spiffe/spike/app/nexus/internal/route/cipher" "github.com/spiffe/spike/app/nexus/internal/route/operator" "github.com/spiffe/spike/app/nexus/internal/route/secret" - "github.com/spiffe/spike/internal/net" ) // routeWithBackingStore maps API actions and URLs to their corresponding diff --git a/app/nexus/internal/route/base/route.go b/app/nexus/internal/route/base/route.go index ffea1c2d..7ce83592 100644 --- a/app/nexus/internal/route/base/route.go +++ b/app/nexus/internal/route/base/route.go @@ -16,10 +16,10 @@ import ( "github.com/spiffe/spike-sdk-go/api/url" "github.com/spiffe/spike-sdk-go/config/env" sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/journal" + "github.com/spiffe/spike-sdk-go/net" + state "github.com/spiffe/spike/app/nexus/internal/state/base" - "github.com/spiffe/spike/internal/net" ) // Route handles all incoming HTTP requests by dynamically selecting and @@ -38,9 +38,9 @@ func Route( url.APIAction(r.URL.Query().Get(url.KeyAPIAction)), r.Method, func(a url.APIAction, p url.APIURL) net.Handler { - // Lite: requires root key. - // SQLite: requires root key. - // Memory: does not require root key. + // Lite: requires the root key. + // SQLite: requires the root key. + // Memory: does not require the root key. emptyRootKey := state.RootKeyZero() inMemoryMode := env.BackendStoreTypeVal() == env.Memory diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index a54b282a..2f48cf5b 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -52,14 +52,11 @@ func guardVerifyRequest( ) *sdkErrors.SDKError { // No CheckAccess because this route is privileged and should not honor // policy overrides. Match exact SPIFFE ID instead. - if _, idErr := net.ExtractPeerSPIFFEIDAndRespondOnFail( - w, r, reqres.BootstrapVerifyResponse{}.Unauthorized(), - ); idErr != nil { - return idErr - } - authErr := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap, - reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r) - if authErr != nil { + if authErr := net.AuthorizeAndRespondOnFailNoPolicy( + reqres.BootstrapVerifyResponse{}.Unauthorized(), + spiffeid.IsBootstrap, + w, r, + ); authErr != nil { return authErr } diff --git a/app/nexus/internal/route/cipher/decrypt_intercept.go b/app/nexus/internal/route/cipher/decrypt_intercept.go index f246c00c..1f465b5f 100644 --- a/app/nexus/internal/route/cipher/decrypt_intercept.go +++ b/app/nexus/internal/route/cipher/decrypt_intercept.go @@ -46,7 +46,7 @@ func guardCipherDecryptRequest( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherDecryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherDecrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index c2f23ca1..6f570704 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -22,7 +22,7 @@ import ( // } // // If not, do a policy check to determine if the request is allowed: // if !allowed { -// allowed = state.CheckAccess( +// allowed = state.CheckPolicyAccess( // spiffeid, // apiAuth.PathSystemCipherExecute, // []data.PolicyPermission{data.PermissionExecute}, @@ -65,7 +65,7 @@ func guardCipherEncryptRequest( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherEncryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherEncrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index 9083edb6..e0eca6c8 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -42,7 +42,7 @@ func handleStreamingDecrypt( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherDecryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherDecrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr @@ -105,7 +105,7 @@ func handleJSONDecrypt( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherDecryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherDecrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr @@ -161,7 +161,7 @@ func handleStreamingEncrypt( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherEncryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherEncrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr @@ -220,7 +220,7 @@ func handleJSONEncrypt( if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherEncryptResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForCipherEncrypt, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/operator/recover_intercept.go b/app/nexus/internal/route/operator/recover_intercept.go index 8fd90d4d..c12ac8ad 100644 --- a/app/nexus/internal/route/operator/recover_intercept.go +++ b/app/nexus/internal/route/operator/recover_intercept.go @@ -44,7 +44,9 @@ func guardRecoverRequest( ) *sdkErrors.SDKError { // No CheckAccess because this route is privileged and should not honor // policy overrides. Match exact SPIFFE ID instead. - return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRecover, - reqres.RestoreResponse{}.Unauthorized(), w, r, + return net.AuthorizeAndRespondOnFailNoPolicy( + reqres.PolicyPutResponse{}.Unauthorized(), + spiffeid.IsPilotRecover, + w, r, ) } diff --git a/app/nexus/internal/route/operator/restore_intercept.go b/app/nexus/internal/route/operator/restore_intercept.go index 1bcd80ec..4ce9873f 100644 --- a/app/nexus/internal/route/operator/restore_intercept.go +++ b/app/nexus/internal/route/operator/restore_intercept.go @@ -50,11 +50,12 @@ func guardRestoreRequest( ) *sdkErrors.SDKError { // No CheckAccess because this route is privileged and should not honor // policy overrides. Match exact SPIFFE ID instead. - err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRestore, - reqres.RestoreResponse{}.Unauthorized(), w, r, - ) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFailNoPolicy( + reqres.RestoreResponse{}.Unauthorized(), + spiffeid.IsPilotRestore, + w, r, + ); authErr != nil { + return authErr } if request.ID < minRequestId || request.ID > env.ShamirMaxShareCountVal() { @@ -64,7 +65,7 @@ func guardRestoreRequest( if failErr != nil { return sdkErrors.ErrAPIBadRequest.Wrap(failErr) } - return sdkErrors.ErrAPIBadRequest + return sdkErrors.ErrAPIBadRequest.Clone() } allZero := true @@ -81,7 +82,7 @@ func guardRestoreRequest( if failErr != nil { return sdkErrors.ErrAPIBadRequest.Wrap(failErr) } - return sdkErrors.ErrAPIBadRequest + return sdkErrors.ErrAPIBadRequest.Clone() } return nil } diff --git a/app/nexus/internal/route/secret/delete_intercept.go b/app/nexus/internal/route/secret/delete_intercept.go index 20a293e5..6e11b690 100644 --- a/app/nexus/internal/route/secret/delete_intercept.go +++ b/app/nexus/internal/route/secret/delete_intercept.go @@ -48,7 +48,7 @@ func guardDeleteSecretRequest( peerSPIFFEID, request.Path, checkAccess, ) }, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/secret/get_intercept.go b/app/nexus/internal/route/secret/get_intercept.go index a405e83d..204d46ee 100644 --- a/app/nexus/internal/route/secret/get_intercept.go +++ b/app/nexus/internal/route/secret/get_intercept.go @@ -49,7 +49,7 @@ func guardGetSecretRequest( peerSPIFFEID, request.Path, checkAccess, ) }, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/secret/list_intercept.go b/app/nexus/internal/route/secret/list_intercept.go index ed6708fa..9a800503 100644 --- a/app/nexus/internal/route/secret/list_intercept.go +++ b/app/nexus/internal/route/secret/list_intercept.go @@ -44,7 +44,7 @@ func guardListSecretRequest( return net.AuthorizeAndRespondOnFail( reqres.SecretListResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForSecretList, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ) } diff --git a/app/nexus/internal/route/secret/metadata_get_intercept.go b/app/nexus/internal/route/secret/metadata_get_intercept.go index f5ef6317..50be9fde 100644 --- a/app/nexus/internal/route/secret/metadata_get_intercept.go +++ b/app/nexus/internal/route/secret/metadata_get_intercept.go @@ -50,7 +50,7 @@ func guardGetSecretMetadataRequest( peerSPIFFEID, request.Path, checkAccess, ) }, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/secret/put_intercept.go b/app/nexus/internal/route/secret/put_intercept.go index 29800211..89546489 100644 --- a/app/nexus/internal/route/secret/put_intercept.go +++ b/app/nexus/internal/route/secret/put_intercept.go @@ -53,7 +53,7 @@ func guardSecretPutRequest( peerSPIFFEID, request.Path, checkAccess, ) }, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/route/secret/undelete_intercept.go b/app/nexus/internal/route/secret/undelete_intercept.go index a901a181..571c7866 100644 --- a/app/nexus/internal/route/secret/undelete_intercept.go +++ b/app/nexus/internal/route/secret/undelete_intercept.go @@ -52,7 +52,7 @@ func guardSecretUndeleteRequest( peerSPIFFEID, request.Path, checkAccess, ) }, - state.CheckAccess, + state.CheckPolicyAccess, w, r, ); authErr != nil { return authErr diff --git a/app/nexus/internal/state/base/doc.go b/app/nexus/internal/state/base/doc.go index a8bcba23..2f8f58e4 100644 --- a/app/nexus/internal/state/base/doc.go +++ b/app/nexus/internal/state/base/doc.go @@ -25,7 +25,7 @@ // - GetPolicy: Retrieve a policy by ID // - DeletePolicy: Remove a policy // - ListPolicies: List policies with optional filtering -// - CheckAccess: Evaluate if a SPIFFE ID has required permissions for a path +// - CheckPolicyAccess: Evaluate if a SPIFFE ID has required permissions for a path // // Access control: // diff --git a/app/nexus/internal/state/base/policy.go b/app/nexus/internal/state/base/policy.go index 85a63069..7be88484 100644 --- a/app/nexus/internal/state/base/policy.go +++ b/app/nexus/internal/state/base/policy.go @@ -16,7 +16,7 @@ import ( "github.com/spiffe/spike/app/nexus/internal/state/persist" ) -// CheckAccess determines if a given SPIFFE ID has the required permissions for +// CheckPolicyAccess determines if a given SPIFFE ID has the required permissions for // a specific path. For SPIKE Pilot (a system workload), access is always // granted without policy checks. For other workloads, the function evaluates // against all defined policies using regular expression pattern matching. @@ -25,7 +25,7 @@ import ( // to read and modify secrets and policies. // // Note that elevated actions such as "recovery" and "restore" DO NOT use -// CheckAccess for access control. These actions require exact actor SPIFFE ID +// CheckPolicyAccess for access control. These actions require exact actor SPIFFE ID // matches and cannot be overridden by policies. // // Parameters: @@ -43,16 +43,11 @@ import ( // // A policy matches when its SPIFFE ID pattern matches the requestor's ID and // its path pattern matches the requested path. -func CheckAccess( +func CheckPolicyAccess( peerSPIFFEID string, path string, wants []data.PolicyPermission, ) bool { - const fName = "CheckAccess" + const fName = "CheckPolicyAccess" - //// SPIKE Pilot is a system workload; no policy check needed. - //if spiffeid.IsPilotOperator(peerSPIFFEID) { - // return true - //} - // policies, err := ListPolicies() if err != nil { log.WarnErr(fName, *sdkErrors.ErrEntityLoadFailed.Clone()) diff --git a/app/nexus/internal/state/base/policy_test.go b/app/nexus/internal/state/base/policy_test.go index 7af16202..01631db8 100644 --- a/app/nexus/internal/state/base/policy_test.go +++ b/app/nexus/internal/state/base/policy_test.go @@ -34,7 +34,7 @@ func TestCheckAccess_PilotAccess(t *testing.T) { // This will return false in practice because we don't have the actual // SPIKE Pilot setup, but the code pathPattern will be tested - result := CheckAccess(pilotSPIFFEID, path, wants) + result := CheckPolicyAccess(pilotSPIFFEID, path, wants) // Since we don't have actual pilot setup, this will test the policy matching pathPattern if result { @@ -69,7 +69,7 @@ func TestCheckAccess_SuperPermission(t *testing.T) { } for _, perm := range permissions { - result := CheckAccess("spiffe://example.org/test", + result := CheckPolicyAccess("spiffe://example.org/test", "any/pathPattern", []data.PolicyPermission{perm}) if !result { t.Errorf("Expected super permission to grant %v access", perm) @@ -149,7 +149,7 @@ func TestCheckAccess_SpecificPatterns(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := CheckAccess(tc.SPIFFEID, tc.path, tc.wants) + result := CheckPolicyAccess(tc.SPIFFEID, tc.path, tc.wants) if result != tc.expectGrant { t.Errorf("Expected %v, got %v for case: %s", tc.expectGrant, result, tc.name) @@ -174,7 +174,7 @@ func TestCheckAccess_LoadPoliciesError(t *testing.T) { persist.InitializeBackend(nil) // Normal case should work - result := CheckAccess("spiffe://example.org/test", + result := CheckPolicyAccess("spiffe://example.org/test", "some/path", []data.PolicyPermission{data.PermissionRead}) // Should be false since no policies exist if result { @@ -845,7 +845,7 @@ func TestPolicyRegexCompilation(t *testing.T) { for i, tc := range testCases { t.Run(fmt.Sprintf("regex_test_%d", i), func(t *testing.T) { - result := CheckAccess(tc.SPIFFEID, tc.path, + result := CheckPolicyAccess(tc.SPIFFEID, tc.path, []data.PolicyPermission{data.PermissionRead}) if result != tc.shouldMatch { t.Errorf("Expected %v for SPIFFEID %s and path %s", @@ -889,7 +889,7 @@ func BenchmarkCheckAccess_WildcardPolicy(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - CheckAccess("spiffe://example.org/test", + CheckPolicyAccess("spiffe://example.org/test", "test/path", []data.PolicyPermission{data.PermissionRead}) } diff --git a/go.mod b/go.mod index 4762d2ea..8eb146e1 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.17.36 + github.com/spiffe/spike-sdk-go v0.18.2 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index e7d414d4..a4dbca34 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,9 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.17.36 h1:Nx4YhosaMnbw0JcJj+aqnr45ySaIMmzhAnFqiZGvAwM= -github.com/spiffe/spike-sdk-go v0.17.36/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.18.1 h1:f/E7GSoGwJasj85iwT1/1t+A5FJaFRBGvivGqdApb8g= +github.com/spiffe/spike-sdk-go v0.18.1/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.18.2/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/internal/net/doc.go b/internal/net/doc.go deleted file mode 100644 index ed61dd76..00000000 --- a/internal/net/doc.go +++ /dev/null @@ -1,36 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -// Package net provides HTTP utilities for SPIKE components. -// -// This package contains shared networking code used by SPIKE Nexus, SPIKE -// Keeper, and other components for handling HTTP requests and responses. -// -// Request handling: -// -// - HandleRoute: Wraps HTTP handlers with audit logging, generating trail -// IDs, and recording request lifecycle events. -// - Handler: Function type for request handlers with audit support. -// -// Response utilities: -// -// - Respond: Writes JSON responses with proper headers and caching controls. -// - Fail: Sends error responses with appropriate HTTP status codes. -// - HandleError: Processes SDK errors and sends 404 or 500 responses. -// - HandleInternalError: Sends 500 responses for internal errors. -// - MarshalBodyAndRespondOnMarshalFail: Safely marshals JSON responses. -// -// HTTP client: -// -// - Post: Performs HTTP POST requests with error handling and response -// body management. -// -// Request parsing: -// -// - ReadBody: Reads and unmarshals JSON request bodies. -// - ValidateSpiffeId: Extracts and validates SPIFFE IDs from requests. -// -// This package is internal to SPIKE and should not be imported by external -// code. -package net diff --git a/internal/net/factory.go b/internal/net/factory.go deleted file mode 100644 index 9bdaa311..00000000 --- a/internal/net/factory.go +++ /dev/null @@ -1,41 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package net - -import ( - "net/http" - - "github.com/spiffe/spike-sdk-go/api/url" - "github.com/spiffe/spike-sdk-go/log" -) - -// RouteFactory creates HTTP route handlers for API endpoints using a generic -// switching function. It enforces POST-only methods per ADR-0012 and logs -// route creation details. -// -// Type Parameters: -// - ApiAction: Type representing the API action to be handled -// -// Parameters: -// - p: API URL for the route -// - a: API action instance -// - m: HTTP method -// - switchyard: Function that returns an appropriate handler based on -// action and URL -// -// Returns: -// - Handler: Route handler function or Fallback for non-POST methods -func RouteFactory[ApiAction any](p url.APIURL, a ApiAction, m string, - switchyard func(a ApiAction, p url.APIURL) Handler) Handler { - log.Info("RouteFactory", "path", p, "action", a, "method", m) - - // We only accept POST requests---See ADR-0012. - // (https://spike.ist/architecture/adrs/adr-0012/) - if m != http.MethodPost { - return Fallback - } - - return switchyard(a, p) -} diff --git a/internal/net/handle.go b/internal/net/handle.go deleted file mode 100644 index 2843a7e6..00000000 --- a/internal/net/handle.go +++ /dev/null @@ -1,76 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package net - -import ( - "net/http" - "time" - - "github.com/spiffe/spike-sdk-go/crypto" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - - "github.com/spiffe/spike-sdk-go/journal" -) - -// Handler is a function type that processes HTTP requests with audit -// logging support. -// -// Parameters: -// - w: HTTP response writer for sending the response -// - r: HTTP request containing the incoming request data -// - audit: Audit entry for logging the request lifecycle -// -// Returns: -// - *sdkErrors.SDKError: nil on success, error on failure -type Handler func( - w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, -) *sdkErrors.SDKError - -// HandleRoute wraps an HTTP handler with audit logging functionality. -// It creates and manages audit log entries for the request lifecycle, -// including -// - Generating unique trail IDs -// - Recording timestamps and durations -// - Tracking request status (created, success, error) -// - Capturing error information -// -// The wrapped handler is mounted at the root path ("/") and automatically -// logs entry and exit audit events for all requests. -// -// Parameters: -// - h: Handler function to wrap with audit logging -func HandleRoute(h Handler) { - http.HandleFunc("/", func( - writer http.ResponseWriter, request *http.Request, - ) { - now := time.Now() - id := crypto.ID() - - entry := journal.AuditEntry{ - TrailID: id, - Timestamp: now, - UserID: "", - Action: journal.AuditEnter, - Path: request.URL.Path, - Resource: "", - SessionID: "", - State: journal.AuditEntryCreated, - } - journal.Audit(entry) - - err := h(writer, request, &entry) - if err == nil { - entry.Action = journal.AuditExit - entry.State = journal.AuditSuccess - } else { - entry.Action = journal.AuditExit - entry.State = journal.AuditErrored - entry.Err = err.Error() - } - - entry.Duration = time.Since(now) - journal.Audit(entry) - }) -} diff --git a/internal/net/response.go b/internal/net/response.go deleted file mode 100644 index d4af604e..00000000 --- a/internal/net/response.go +++ /dev/null @@ -1,72 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package net - -import ( - "net/http" - - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/journal" - "github.com/spiffe/spike-sdk-go/net" -) - -// Fallback handles requests to undefined routes by returning a 400 Bad Request. -// -// This function serves as a catch-all handler for undefined routes, logging the -// request details and returning a standardized error response. It uses -// MarshalBodyAndRespondOnMarshalFail to generate the response and handles any -// errors during response writing. -// -// Parameters: -// - w: http.ResponseWriter - The response writer -// - r: *http.Request - The incoming request -// - audit: *journal.AuditEntry - The audit log entry for this request -// -// The response always includes: -// - Status: 400 Bad Request -// - Content-Type: application/json -// - Body: JSON object with an error field -// -// Returns: -// - *sdkErrors.SDKError: nil on success, or sdkErrors.ErrAPIInternal if -// response marshaling or writing fails -func Fallback( - w http.ResponseWriter, _ *http.Request, audit *journal.AuditEntry, -) *sdkErrors.SDKError { - audit.Action = journal.AuditFallback - - return net.RespondFallbackWithStatus( - w, http.StatusBadRequest, sdkErrors.ErrAPIBadRequest.Code, - ) -} - -// NotReady handles requests when the system has not initialized its backing -// store with a root key by returning a 503 Service Unavailable. -// -// This function uses MarshalBodyAndRespondOnMarshalFail to generate the -// response and handles any errors during response writing. -// -// Parameters: -// - w: http.ResponseWriter - The response writer -// - r: *http.Request - The incoming request -// - audit: *journal.AuditEntry - The audit log entry for this request -// -// The response always includes: -// - Status: 503 Service Unavailable -// - Content-Type: application/json -// - Body: JSON object with an error field containing ErrStateNotReady -// -// Returns: -// - *sdkErrors.SDKError: nil on success, or sdkErrors.ErrAPIInternal if -// response marshaling or writing fails -func NotReady( - w http.ResponseWriter, _ *http.Request, audit *journal.AuditEntry, -) *sdkErrors.SDKError { - audit.Action = journal.AuditBlocked - - return net.RespondFallbackWithStatus( - w, http.StatusServiceUnavailable, sdkErrors.ErrStateNotReady.Code, - ) -} diff --git a/internal/net/response_test.go b/internal/net/response_test.go deleted file mode 100644 index 76213dc9..00000000 --- a/internal/net/response_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package net - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/spiffe/spike-sdk-go/net" - - sdkErrors "github.com/spiffe/spike-sdk-go/errors" -) - -type testResponse struct { - Message string `json:"message"` - Code int `json:"code"` -} - -func TestMarshalBodyAndRespondOnMarshalFail_Success(t *testing.T) { - w := httptest.NewRecorder() - res := testResponse{Message: "success", Code: 200} - - body, err := net.MarshalBodyAndRespondOnMarshalFail(res, w) - - if err != nil { - t.Errorf("MarshalBodyAndRespondOnMarshalFail() error = %v, want nil", err) - } - - if body == nil { - t.Error("MarshalBodyAndRespondOnMarshalFail() body is nil") - } - - // Verify JSON is valid - var decoded testResponse - if unmarshalErr := json.Unmarshal(body, &decoded); unmarshalErr != nil { - t.Errorf("MarshalBodyAndRespondOnMarshalFail() invalid JSON: %v", - unmarshalErr) - } - - if decoded.Message != "success" { - t.Errorf("MarshalBodyAndRespondOnMarshalFail() message = %q, want %q", - decoded.Message, "success") - } - - // No response should be written on success - if w.Code != http.StatusOK { - t.Errorf("MarshalBodyAndRespondOnMarshalFail() wrote status %d on success", - w.Code) - } -} - -func TestMarshalBodyAndRespondOnMarshalFail_UnmarshalableType(t *testing.T) { - w := httptest.NewRecorder() - // Channels cannot be marshaled to JSON - res := make(chan int) - - body, err := net.MarshalBodyAndRespondOnMarshalFail(res, w) - - if err == nil { - t.Error("MarshalBodyAndRespondOnMarshalFail() expected error for channel") - } - - if body != nil { - t.Error("MarshalBodyAndRespondOnMarshalFail() body should be nil on error") - } - - if w.Code != http.StatusInternalServerError { - t.Errorf("MarshalBodyAndRespondOnMarshalFail() status = %d, want %d", - w.Code, http.StatusInternalServerError) - } -} - -func TestRespond_SetsHeaders(t *testing.T) { - w := httptest.NewRecorder() - body := []byte(`{"test":"value"}`) - - _ = net.Respond(http.StatusOK, body, w) - - // Check Content-Type - if ct := w.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Respond() Content-Type = %q, want %q", ct, "application/json") - } - - // Check Cache-Control - cc := w.Header().Get("Cache-Control") - if cc != "no-store, no-cache, must-revalidate, private" { - t.Errorf("Respond() Cache-Control = %q, want no-cache headers", cc) - } - - // Check Pragma - if pragma := w.Header().Get("Pragma"); pragma != "no-cache" { - t.Errorf("Respond() Pragma = %q, want %q", pragma, "no-cache") - } - - // Check Expires - if expires := w.Header().Get("Expires"); expires != "0" { - t.Errorf("Respond() Expires = %q, want %q", expires, "0") - } - - // Check status code - if w.Code != http.StatusOK { - t.Errorf("Respond() status = %d, want %d", w.Code, http.StatusOK) - } - - // Check body - if w.Body.String() != `{"test":"value"}` { - t.Errorf("Respond() body = %q, want %q", - w.Body.String(), `{"test":"value"}`) - } -} - -func TestRespond_DifferentStatusCodes(t *testing.T) { - tests := []struct { - name string - statusCode int - }{ - {"OK", http.StatusOK}, - {"Created", http.StatusCreated}, - {"BadRequest", http.StatusBadRequest}, - {"Unauthorized", http.StatusUnauthorized}, - {"Forbidden", http.StatusForbidden}, - {"NotFound", http.StatusNotFound}, - {"InternalServerError", http.StatusInternalServerError}, - {"ServiceUnavailable", http.StatusServiceUnavailable}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - body := []byte(`{}`) - - _ = net.Respond(tt.statusCode, body, w) - - if w.Code != tt.statusCode { - t.Errorf("Respond() status = %d, want %d", w.Code, tt.statusCode) - } - }) - } -} - -// mockErrorResponse implements ErrorResponder for testing HandleError. -type mockErrorResponse struct { - Err string `json:"err"` -} - -func (m mockErrorResponse) NotFound() mockErrorResponse { - return mockErrorResponse{Err: "not_found"} -} - -func (m mockErrorResponse) Internal() mockErrorResponse { - return mockErrorResponse{Err: "internal"} -} - -func TestHandleError_NilError(t *testing.T) { - w := httptest.NewRecorder() - - result := net.RespondWithHTTPError(nil, w, mockErrorResponse{}) - - if result != nil { - t.Errorf("HandleError(nil) = %v, want nil", result) - } - - // Response should not have been written - if w.Code != http.StatusOK { - t.Errorf("HandleError(nil) wrote response, status = %d", w.Code) - } -} - -func TestHandleError_NotFoundError(t *testing.T) { - w := httptest.NewRecorder() - - err := sdkErrors.ErrEntityNotFound.Clone() - result := net.RespondWithHTTPError(err, w, mockErrorResponse{}) - - if result == nil { - t.Error("HandleError() returned nil for not found error") - } - - if w.Code != http.StatusNotFound { - t.Errorf("HandleError() status = %d, want %d", - w.Code, http.StatusNotFound) - } -} - -func TestHandleError_OtherError(t *testing.T) { - w := httptest.NewRecorder() - - err := sdkErrors.ErrAPIBadRequest.Clone() - result := net.RespondWithHTTPError(err, w, mockErrorResponse{}) - - if result == nil { - t.Error("HandleError() returned nil for other error") - } - - if w.Code != http.StatusInternalServerError { - t.Errorf("HandleError() status = %d, want %d", - w.Code, http.StatusInternalServerError) - } -} - -func TestHandleError_WrappedNotFoundError(t *testing.T) { - w := httptest.NewRecorder() - - // Create a wrapped not found error - wrappedErr := sdkErrors.ErrEntityNotFound.Wrap(sdkErrors.ErrAPIBadRequest) - result := net.RespondWithHTTPError(wrappedErr, w, mockErrorResponse{}) - - if result == nil { - t.Error("HandleError() returned nil for wrapped not found error") - } - - // Should still be recognized as not found - if w.Code != http.StatusNotFound { - t.Errorf("HandleError() status = %d, want %d", - w.Code, http.StatusNotFound) - } -} - -func TestHandleInternalError(t *testing.T) { - w := httptest.NewRecorder() - - err := sdkErrors.ErrCryptoCipherNotAvailable.Clone() - result := net.RespondWithInternalError(err, w, mockErrorResponse{}) - - if result == nil { - t.Error("HandleInternalError() returned nil") - } - - if !errors.Is(result, err) { - t.Error("HandleInternalError() should return the same error") - } - - if w.Code != http.StatusInternalServerError { - t.Errorf("HandleInternalError() status = %d, want %d", - w.Code, http.StatusInternalServerError) - } -} From 6006986ddb0856a9abbeecb6a8e726f4d799d065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Sun, 18 Jan 2026 07:38:54 -0800 Subject: [PATCH 22/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../route/operator/restore_intercept.go | 35 ++++--------------- .../internal/route/operator/restore_test.go | 21 ++++++----- app/nexus/internal/route/secret/delete.go | 2 +- .../internal/route/secret/delete_intercept.go | 5 ++- 4 files changed, 22 insertions(+), 41 deletions(-) diff --git a/app/nexus/internal/route/operator/restore_intercept.go b/app/nexus/internal/route/operator/restore_intercept.go index 4ce9873f..ca2f1d73 100644 --- a/app/nexus/internal/route/operator/restore_intercept.go +++ b/app/nexus/internal/route/operator/restore_intercept.go @@ -8,14 +8,11 @@ import ( "net/http" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - "github.com/spiffe/spike-sdk-go/config/env" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/spiffeid" ) -const minRequestId = 1 - // guardRestoreRequest validates a system restore request by performing // authentication, authorization, and input validation checks. // @@ -58,31 +55,13 @@ func guardRestoreRequest( return authErr } - if request.ID < minRequestId || request.ID > env.ShamirMaxShareCountVal() { - failErr := net.Fail( - reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrAPIBadRequest.Wrap(failErr) - } - return sdkErrors.ErrAPIBadRequest.Clone() + if idErr := net.RespondErrOnBadRequestID( + request.ID, reqres.RestoreResponse{}.BadRequest(), w, + ); idErr != nil { + return idErr } - allZero := true - for _, b := range request.Shard { - if b != 0 { - allZero = false - break - } - } - if allZero { - failErr := net.Fail( - reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrAPIBadRequest.Wrap(failErr) - } - return sdkErrors.ErrAPIBadRequest.Clone() - } - return nil + return net.RespondErrOnEmptyShard( + request.Shard, reqres.RestoreResponse{}.BadRequest(), w, + ) } diff --git a/app/nexus/internal/route/operator/restore_test.go b/app/nexus/internal/route/operator/restore_test.go index 060e2a85..45cbb0d8 100644 --- a/app/nexus/internal/route/operator/restore_test.go +++ b/app/nexus/internal/route/operator/restore_test.go @@ -18,7 +18,6 @@ import ( "github.com/spiffe/spike-sdk-go/crypto" "github.com/spiffe/spike-sdk-go/journal" - "github.com/spiffe/spike/app/nexus/internal/initialization/recovery" ) func TestRouteRestore_MemoryMode(t *testing.T) { @@ -157,7 +156,7 @@ func TestRouteRestore_TooManyShards(t *testing.T) { for i := 1; i <= 3; i++ { // Exceed the threshold of 2 testData := &[crypto.AES256KeySize]byte{} testData[0] = byte(i) // Make each shard unique and non-zero - shards = append(shards, recovery.ShamirShard{ + shards = append(shards, crypto.ShamirShard{ ID: uint64(i), Value: testData, }) @@ -213,7 +212,7 @@ func TestRouteRestore_DuplicateShard(t *testing.T) { testData := &[crypto.AES256KeySize]byte{} testData[0] = 1 // Non-zero shardsMutex.Lock() - shards = append(shards, recovery.ShamirShard{ + shards = append(shards, crypto.ShamirShard{ ID: 1, Value: testData, }) @@ -265,21 +264,21 @@ func TestShardCollectionLogic(t *testing.T) { tests := []struct { name string - existingShards []recovery.ShamirShard + existingShards []crypto.ShamirShard newShardID uint64 expectedCount int expectThreshold bool }{ { name: "first shard", - existingShards: []recovery.ShamirShard{}, + existingShards: []crypto.ShamirShard{}, newShardID: 1, expectedCount: 1, expectThreshold: false, }, { name: "second shard", - existingShards: []recovery.ShamirShard{ + existingShards: []crypto.ShamirShard{ {ID: 1, Value: createTestShardValue(1)}, }, newShardID: 2, @@ -288,7 +287,7 @@ func TestShardCollectionLogic(t *testing.T) { }, { name: "third shard - reaches threshold", - existingShards: []recovery.ShamirShard{ + existingShards: []crypto.ShamirShard{ {ID: 1, Value: createTestShardValue(1)}, {ID: 2, Value: createTestShardValue(2)}, }, @@ -315,7 +314,7 @@ func TestShardCollectionLogic(t *testing.T) { // Simulate adding a new shard if currentCount < threshold { shardsMutex.Lock() - shards = append(shards, recovery.ShamirShard{ + shards = append(shards, crypto.ShamirShard{ ID: tt.newShardID, Value: createTestShardValue(int(tt.newShardID)), }) @@ -340,7 +339,7 @@ func TestShardDuplicateDetection(t *testing.T) { resetShards() // Add initial shards - testShards := []recovery.ShamirShard{ + testShards := []crypto.ShamirShard{ {ID: 1, Value: createTestShardValue(1)}, {ID: 3, Value: createTestShardValue(3)}, {ID: 5, Value: createTestShardValue(5)}, @@ -397,7 +396,7 @@ func TestShardSecurityCleanup(t *testing.T) { // Reset and add shards that would trigger restoration resetShards() - testShards := []recovery.ShamirShard{ + testShards := []crypto.ShamirShard{ {ID: 1, Value: createTestShardValue(1)}, {ID: 2, Value: createTestShardValue(2)}, } @@ -477,7 +476,7 @@ func TestConcurrentShardAccess(t *testing.T) { shardID := uint64(goroutineID*shardsPerGoroutine + j + 1) shardsMutex.Lock() - shards = append(shards, recovery.ShamirShard{ + shards = append(shards, crypto.ShamirShard{ ID: shardID, Value: createTestShardValue(int(shardID)), }) diff --git a/app/nexus/internal/route/secret/delete.go b/app/nexus/internal/route/secret/delete.go index 6850ba2a..d20abe3b 100644 --- a/app/nexus/internal/route/secret/delete.go +++ b/app/nexus/internal/route/secret/delete.go @@ -9,9 +9,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) diff --git a/app/nexus/internal/route/secret/delete_intercept.go b/app/nexus/internal/route/secret/delete_intercept.go index 6e11b690..d156473a 100644 --- a/app/nexus/internal/route/secret/delete_intercept.go +++ b/app/nexus/internal/route/secret/delete_intercept.go @@ -39,8 +39,11 @@ import ( func guardDeleteSecretRequest( request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - if authErr := net.AuthorizeAndRespondOnFail( + if authErr := net.AuthorizeAndRespondOnFail( // TODO: AuthorizeAndRespondOnFailForPath(peerSPIFFEID, path, checkAccess) reqres.SecretDeleteResponse{}.Unauthorized(), + + // TODO: type ForPathWithPolicyAccessChecker func(string peerSPIFFEID, string path, PolicyAccessChecker) bool + func( peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, ) bool { From 41908faa7746041c86758fcd9cf34580bdf5b15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Sun, 18 Jan 2026 21:39:57 +0000 Subject: [PATCH 23/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/nexus/cmd/main.go | 2 +- .../initialization/recovery/keeper.go | 2 +- app/nexus/internal/route/acl/policy/get.go | 2 +- app/nexus/internal/route/acl/policy/list.go | 2 +- .../route/acl/policy/list_intercept.go | 8 +- app/nexus/internal/route/base/guard_test.go | 1 - app/nexus/internal/route/bootstrap/verify.go | 12 +- .../route/bootstrap/verify_intercept.go | 5 - app/nexus/internal/route/cipher/config.go | 6 - app/nexus/internal/route/cipher/crypto.go | 171 ------------------ app/nexus/internal/route/cipher/decrypt.go | 32 +--- app/nexus/internal/route/cipher/doc.go | 1 - app/nexus/internal/route/cipher/encrypt.go | 29 +-- app/nexus/internal/route/cipher/handle.go | 129 +++++++------ app/nexus/internal/route/cipher/read.go | 10 +- app/nexus/internal/route/cipher/state.go | 64 ------- app/nexus/internal/route/operator/recover.go | 2 +- .../route/operator/recover_intercept.go | 10 +- app/nexus/internal/route/operator/restore.go | 2 +- app/nexus/internal/route/secret/delete.go | 1 - app/nexus/internal/state/backend/interface.go | 6 + .../internal/state/backend/lite/initialize.go | 12 +- .../internal/state/backend/memory/memory.go | 13 +- app/nexus/internal/state/backend/noop/noop.go | 10 + .../state/backend/sqlite/persist/cipher.go | 12 +- .../state/backend/sqlite/persist/doc.go | 1 - go.mod | 2 +- go.sum | 5 +- 28 files changed, 176 insertions(+), 376 deletions(-) delete mode 100644 app/nexus/internal/route/cipher/crypto.go diff --git a/app/nexus/cmd/main.go b/app/nexus/cmd/main.go index 7fbd92dd..3c50f93b 100644 --- a/app/nexus/cmd/main.go +++ b/app/nexus/cmd/main.go @@ -79,7 +79,7 @@ func main() { // have a legitimate SPIFFE ID registration entry. // we might want to further restrict this based on environment // configuration maybe (for example, a predicate that checks regex - // matching on workload SPIFFFE IDs before granting access, + // matching on workload SPIFFE IDs before granting access, // if the matcher is not provided, AllowAll will be assumed). predicate.AllowAll, env.NexusTLSPortVal(), diff --git a/app/nexus/internal/initialization/recovery/keeper.go b/app/nexus/internal/initialization/recovery/keeper.go index 3cfe7121..e378f7c5 100644 --- a/app/nexus/internal/initialization/recovery/keeper.go +++ b/app/nexus/internal/initialization/recovery/keeper.go @@ -68,7 +68,7 @@ func iterateKeepersAndInitializeState( // For persistent backends, X509 source is required for mTLS with keepers. // We warn and return false (triggering retry) rather than crashing because: - // 1. This function runs in retry.Forever() - designed for transient failures + // 1. This function runs in retry.Forever() (designed for transient failures) // 2. Workload API may temporarily lose source and recover // 3. Returning false allows the system to retry and recover gracefully if source == nil { diff --git a/app/nexus/internal/route/acl/policy/get.go b/app/nexus/internal/route/acl/policy/get.go index c82d3393..f629bb73 100644 --- a/app/nexus/internal/route/acl/policy/get.go +++ b/app/nexus/internal/route/acl/policy/get.go @@ -9,9 +9,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) diff --git a/app/nexus/internal/route/acl/policy/list.go b/app/nexus/internal/route/acl/policy/list.go index 5e805c2c..87534a7f 100644 --- a/app/nexus/internal/route/acl/policy/list.go +++ b/app/nexus/internal/route/acl/policy/list.go @@ -10,9 +10,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/data" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) diff --git a/app/nexus/internal/route/acl/policy/list_intercept.go b/app/nexus/internal/route/acl/policy/list_intercept.go index c5d761a6..499f52b0 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -37,10 +37,14 @@ import ( func guardListPolicyRequest( _ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.AuthorizeAndRespondOnFail( + if authErr := net.AuthorizeAndRespondOnFail( reqres.PolicyListResponse{}.Unauthorized(), predicate.AllowSPIFFEIDForPolicyList, state.CheckPolicyAccess, w, r, - ) + ); authErr != nil { + return authErr + } + + return nil } diff --git a/app/nexus/internal/route/base/guard_test.go b/app/nexus/internal/route/base/guard_test.go index e86123cf..1125a668 100644 --- a/app/nexus/internal/route/base/guard_test.go +++ b/app/nexus/internal/route/base/guard_test.go @@ -117,7 +117,6 @@ func isUtilityFile(name string) bool { "errors.go", "map.go", "config.go", - "crypto.go", "handle.go", "net.go", "state.go", diff --git a/app/nexus/internal/route/bootstrap/verify.go b/app/nexus/internal/route/bootstrap/verify.go index 52156e9a..c16b3b7a 100644 --- a/app/nexus/internal/route/bootstrap/verify.go +++ b/app/nexus/internal/route/bootstrap/verify.go @@ -11,9 +11,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike/app/nexus/internal/state/persist" ) @@ -59,19 +59,19 @@ func RouteVerify( journal.AuditRequest(fName, r, audit, journal.AuditCreate) - request, err := net.ReadParseAndGuard[ + request, parseErr := net.ReadParseAndGuard[ reqres.BootstrapVerifyRequest, reqres.BootstrapVerifyResponse]( w, r, reqres.BootstrapVerifyResponse{}.BadRequest(), guardVerifyRequest, ) - if alreadyResponded := err != nil; alreadyResponded { - return err + if alreadyResponded := parseErr != nil; alreadyResponded { + return parseErr } // Get cipher from the backend c := persist.Backend().GetCipher() if c == nil { return net.RespondWithInternalError( - sdkErrors.ErrCryptoCipherNotAvailable, w, + sdkErrors.ErrCryptoCipherNotAvailable.Clone(), w, reqres.BootstrapVerifyResponse{}, ) } @@ -80,7 +80,7 @@ func RouteVerify( plaintext, decryptErr := c.Open(nil, request.Nonce, request.Ciphertext, nil) if decryptErr != nil { return net.RespondWithInternalError( - sdkErrors.ErrCryptoDecryptionFailed, w, + sdkErrors.ErrCryptoDecryptionFailed.Clone(), w, reqres.BootstrapVerifyResponse{}, ) } diff --git a/app/nexus/internal/route/bootstrap/verify_intercept.go b/app/nexus/internal/route/bootstrap/verify_intercept.go index 2f48cf5b..188c56f3 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -8,16 +8,11 @@ import ( "net/http" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - "github.com/spiffe/spike-sdk-go/crypto" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/spiffeid" ) -// expectedNonceSize is the standard AES-GCM nonce size. See ADR-0032. -// (https://spike.ist/architecture/adrs/adr-0032/) -const expectedNonceSize = crypto.GCMNonceSize - // guardVerifyRequest validates a bootstrap verification request by performing // authentication and input validation checks. // diff --git a/app/nexus/internal/route/cipher/config.go b/app/nexus/internal/route/cipher/config.go index 7ba4bf28..165d336a 100644 --- a/app/nexus/internal/route/cipher/config.go +++ b/app/nexus/internal/route/cipher/config.go @@ -4,12 +4,6 @@ package cipher -import "github.com/spiffe/spike-sdk-go/crypto" - const spikeCipherVersion = byte('1') const headerKeyContentType = "Content-Type" const headerValueOctetStream = "application/octet-stream" - -// expectedNonceSize is the standard AES-GCM nonce size. See ADR-0032. -// (https://spike.ist/architecture/adrs/adr-0012/) -const expectedNonceSize = crypto.GCMNonceSize diff --git a/app/nexus/internal/route/cipher/crypto.go b/app/nexus/internal/route/cipher/crypto.go deleted file mode 100644 index b542e8a2..00000000 --- a/app/nexus/internal/route/cipher/crypto.go +++ /dev/null @@ -1,171 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher - -import ( - "crypto/cipher" - "crypto/rand" - "io" - "net/http" - - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - - "github.com/spiffe/spike-sdk-go/net" -) - -// decryptDataStreaming performs decryption for streaming mode requests. -// -// Parameters: -// - nonce: The nonce bytes -// - ciphertext: The encrypted data -// - c: The cipher to use for decryption -// - w: The HTTP response writer for error responses -// -// Returns: -// - plaintext: The decrypted data if successful -// - *sdkErrors.SDKError: An error if decryption fails -func decryptDataStreaming( - nonce, ciphertext []byte, c cipher.AEAD, w http.ResponseWriter, -) ([]byte, *sdkErrors.SDKError) { - plaintext, err := c.Open(nil, nonce, ciphertext, nil) - if err != nil { - http.Error(w, "decryption failed", http.StatusBadRequest) - return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(err) - } - - return plaintext, nil -} - -// decryptDataJSON performs decryption for JSON mode requests. -// -// Parameters: -// - nonce: The nonce bytes -// - ciphertext: The encrypted data -// - c: The cipher to use for decryption -// - w: The HTTP response writer for error responses -// -// Returns: -// - plaintext: The decrypted data if successful -// - *sdkErrors.SDKError: An error if decryption fails -func decryptDataJSON( - nonce, ciphertext []byte, c cipher.AEAD, w http.ResponseWriter, -) ([]byte, *sdkErrors.SDKError) { - plaintext, err := c.Open(nil, nonce, ciphertext, nil) - if err != nil { - failErr := net.Fail( - reqres.CipherDecryptResponse{}.Internal(), w, - http.StatusInternalServerError, - ) - if failErr != nil { - return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(err).Wrap(failErr) - } - return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(err) - } - - return plaintext, nil -} - -// generateNonceOrFailStreaming generates a cryptographically secure random -// nonce for streaming mode requests. -// -// Parameters: -// - c: The cipher to determine nonce size -// - w: The HTTP response writer for error responses -// -// Returns: -// - nonce: The generated nonce bytes if successful -// - *sdkErrors.SDKError: An error if nonce generation fails -func generateNonceOrFailStreaming( - c cipher.AEAD, w http.ResponseWriter, -) ([]byte, *sdkErrors.SDKError) { - nonce := make([]byte, c.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - http.Error( - w, string(sdkErrors.ErrCryptoNonceGenerationFailed.Code), - http.StatusInternalServerError, - ) - return nil, sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(err) - } - - return nonce, nil -} - -// generateNonceOrFailJSON generates a cryptographically secure random nonce -// for JSON mode requests. -// -// Parameters: -// - c: The cipher to determine nonce size -// - w: The HTTP response writer for error responses -// - errorResponse: The error response to send on failure -// -// Returns: -// - nonce: The generated nonce bytes if successful -// - *sdkErrors.SDKError: An error if nonce generation fails -func generateNonceOrFailJSON[T any]( - c cipher.AEAD, w http.ResponseWriter, errorResponse T, -) ([]byte, *sdkErrors.SDKError) { - nonce := make([]byte, c.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - failErr := net.Fail(errorResponse, w, http.StatusInternalServerError) - if failErr != nil { - return nil, sdkErrors.ErrCryptoNonceGenerationFailed.Wrap( - err).Wrap(failErr) - } - return nil, sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(err) - } - - return nonce, nil -} - -// encryptDataStreaming generates a nonce, performs encryption, and returns -// the nonce and ciphertext for streaming mode requests. -// -// Parameters: -// - plaintext: The data to encrypt -// - c: The cipher to use for encryption -// - w: The HTTP response writer for error responses -// -// Returns: -// - nonce: The generated nonce bytes -// - ciphertext: The encrypted data -// - *sdkErrors.SDKError: An error if nonce generation fails -func encryptDataStreaming( - plaintext []byte, c cipher.AEAD, w http.ResponseWriter, -) ([]byte, []byte, *sdkErrors.SDKError) { - nonce, err := generateNonceOrFailStreaming(c, w) - if err != nil { - return nil, nil, err - } - - ciphertext := c.Seal(nil, nonce, plaintext, nil) - return nonce, ciphertext, nil -} - -// encryptDataJSON generates a nonce, performs encryption, and returns the -// nonce and ciphertext for JSON mode requests. -// -// Parameters: -// - plaintext: The data to encrypt -// - c: The cipher to use for encryption -// - w: The HTTP response writer for error responses -// -// Returns: -// - nonce: The generated nonce bytes -// - ciphertext: The encrypted data -// - *sdkErrors.SDKError: An error if nonce generation fails -func encryptDataJSON( - plaintext []byte, c cipher.AEAD, w http.ResponseWriter, -) ([]byte, []byte, *sdkErrors.SDKError) { - nonce, err := generateNonceOrFailJSON( - c, w, reqres.CipherEncryptResponse{}.Internal(), - ) - if err != nil { - return nil, nil, err - } - - ciphertext := c.Seal(nil, nonce, plaintext, nil) - return nonce, ciphertext, nil -} diff --git a/app/nexus/internal/route/cipher/decrypt.go b/app/nexus/internal/route/cipher/decrypt.go index 06bfb9dc..06f752fa 100644 --- a/app/nexus/internal/route/cipher/decrypt.go +++ b/app/nexus/internal/route/cipher/decrypt.go @@ -5,13 +5,13 @@ package cipher import ( - "crypto/cipher" "net/http" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/journal" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike/app/nexus/internal/state/persist" ) // RouteDecrypt handles HTTP requests to decrypt ciphertext data using the @@ -56,26 +56,14 @@ import ( func RouteDecrypt( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { - const fName = "routeDecrypt" + const fName = "RouteDecrypt" journal.AuditRequest(fName, r, audit, journal.AuditCreate) - // Check if streaming mode based on Content-Type - contentType := r.Header.Get(headerKeyContentType) - streamModeActive := contentType == headerValueOctetStream - - if streamModeActive { - // Cipher getter for streaming mode - getCipher := func() (cipher.AEAD, *sdkErrors.SDKError) { - return getCipherOrFailStreaming(w) - } - return handleStreamingDecrypt(w, r, getCipher) - } - - // Cipher getter for JSON mode - getCipher := func() (cipher.AEAD, *sdkErrors.SDKError) { - return getCipherOrFailJSON( - w, reqres.CipherDecryptResponse{Err: sdkErrors.ErrAPIInternal.Code}, - ) - } - return handleJSONDecrypt(w, r, getCipher) + return net.DispatchByContentType( + w, r, + handleStreamingDecrypt, + handleJSONDecrypt, + persist.Backend().GetCipher, + reqres.CipherDecryptResponse{}.Internal(), + ) } diff --git a/app/nexus/internal/route/cipher/doc.go b/app/nexus/internal/route/cipher/doc.go index ad495703..b09c2fae 100644 --- a/app/nexus/internal/route/cipher/doc.go +++ b/app/nexus/internal/route/cipher/doc.go @@ -84,7 +84,6 @@ // // Request handling is organized into focused functions: // - config.go: Constants (version byte, content-type headers, nonce size) -// - crypto.go: Cryptographic operations (encrypt/decrypt data) // - decrypt.go: RouteDecrypt HTTP handler entry point // - decrypt_intercept.go: Decryption guards and SPIFFE ID extraction // - encrypt.go: RouteEncrypt HTTP handler entry point diff --git a/app/nexus/internal/route/cipher/encrypt.go b/app/nexus/internal/route/cipher/encrypt.go index 16b5e787..ecb42c30 100644 --- a/app/nexus/internal/route/cipher/encrypt.go +++ b/app/nexus/internal/route/cipher/encrypt.go @@ -5,11 +5,12 @@ package cipher import ( - "crypto/cipher" "net/http" "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/net" + "github.com/spiffe/spike/app/nexus/internal/state/persist" "github.com/spiffe/spike-sdk-go/journal" ) @@ -58,23 +59,11 @@ func RouteEncrypt( journal.AuditRequest(fName, r, audit, journal.AuditCreate) - // Check if streaming mode based on Content-Type - contentType := r.Header.Get(headerKeyContentType) - streamModeActive := contentType == headerValueOctetStream - - if streamModeActive { - // Cipher getter for streaming mode - getCipher := func() (cipher.AEAD, *sdkErrors.SDKError) { - return getCipherOrFailStreaming(w) - } - return handleStreamingEncrypt(w, r, getCipher) - } - - // Cipher getter for JSON mode - getCipher := func() (cipher.AEAD, *sdkErrors.SDKError) { - return getCipherOrFailJSON( - w, reqres.CipherEncryptResponse{Err: sdkErrors.ErrAPIInternal.Code}, - ) - } - return handleJSONEncrypt(w, r, getCipher) + return net.DispatchByContentType( + w, r, + handleStreamingEncrypt, + handleJSONEncrypt, + persist.Backend().GetCipher, + reqres.CipherEncryptResponse{}.Internal(), + ) } diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index e0eca6c8..2e5f589a 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -75,7 +75,7 @@ func handleStreamingDecrypt( return guardErr } - plaintext, decryptErr := decryptDataStreaming(nonce, ciphertext, c, w) + plaintext, decryptErr := net.DecryptDataStreaming(nonce, ciphertext, c, w) if decryptErr != nil { return decryptErr } @@ -129,7 +129,7 @@ func handleJSONDecrypt( return cipherErr } - plaintext, decryptErr := decryptDataJSON( + plaintext, decryptErr := net.DecryptDataJSON( request.Nonce, request.Ciphertext, c, w, ) if decryptErr != nil { @@ -157,45 +157,83 @@ func handleStreamingEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - // Extract and validate SPIFFE ID before accessing cipher - if authErr := net.AuthorizeAndRespondOnFail( - reqres.CipherEncryptResponse{}.Unauthorized(), - predicate.AllowSPIFFEIDForCipherEncrypt, - state.CheckPolicyAccess, + req, err := readAndGuardRequest( + readStreamingEncryptRequestWithoutGuard, + guardCipherEncryptRequest, w, r, - ); authErr != nil { - return authErr + ) + if err != nil { + return err } - // Read plaintext (doesn't need cipher) - plaintext, readErr := readStreamingEncryptRequestWithoutGuard(w, r) - if readErr != nil { - return readErr + nonce, ciphertext, encryptErr := getCipherAndEncrypt(getCipher, net.EncryptDataStreaming, req.Plaintext, w) + if encryptErr != nil { + return encryptErr } - // Construct request object for guard validation - request := reqres.CipherEncryptRequest{ - Plaintext: plaintext, + return respondStreamingEncrypt(nonce, ciphertext, w) +} + +type Handler[T any] func(w http.ResponseWriter, r *http.Request) (*T, *sdkErrors.SDKError) +type HandlerWithEntity[T any] func(req T, w http.ResponseWriter, r *http.Request) *sdkErrors.SDKError + +type Encryptor func(plaintext []byte, c cipher.AEAD, w http.ResponseWriter) ([]byte, []byte, *sdkErrors.SDKError) + +// readAndGuardRequest reads and parses a request, then validates it using the +// provided guard function. This is similar to net.ReadParseAndGuard but accepts +// a custom reader function for streaming mode support. +// +// Parameters: +// - readRequest: Function to read and parse the request body +// - guard: Function to validate the parsed request (handles auth and fields) +// - w: The HTTP response writer +// - r: The HTTP request +// +// Returns: +// - *T: The parsed and validated request +// - *sdkErrors.SDKError: An error if reading or validation fails +func readAndGuardRequest[T any]( + readRequest Handler[T], + guard HandlerWithEntity[T], + w http.ResponseWriter, r *http.Request, +) (*T, *sdkErrors.SDKError) { + request, readErr := readRequest(w, r) + if readErr != nil { + return nil, readErr } - // Full guard validation (auth and request fields) - guardErr := guardCipherEncryptRequest(request, w, r) - if guardErr != nil { - return guardErr + if guardErr := guard(*request, w, r); guardErr != nil { + return nil, guardErr } - // Get cipher only after auth passes + return request, nil +} + +// getCipherAndEncrypt retrieves the cipher and encrypts the provided data. +// This combines cipher acquisition and encryption into a single operation. +// +// Parameters: +// - getCipher: Function to retrieve the AEAD cipher +// - encryptData: The encryption function to use +// - plaintext: The data to encrypt +// - w: The HTTP response writer for error responses +// +// Returns: +// - []byte: The generated nonce +// - []byte: The encrypted ciphertext +// - *sdkErrors.SDKError: An error if cipher retrieval or encryption fails +func getCipherAndEncrypt( + getCipher func() (cipher.AEAD, *sdkErrors.SDKError), + encryptData Encryptor, + plaintext []byte, + w http.ResponseWriter, +) ([]byte, []byte, *sdkErrors.SDKError) { c, cipherErr := getCipher() if cipherErr != nil { - return cipherErr - } - - nonce, ciphertext, encryptErr := encryptDataStreaming(plaintext, c, w) - if encryptErr != nil { - return encryptErr + return nil, nil, cipherErr } - return respondStreamingEncrypt(nonce, ciphertext, w) + return encryptData(plaintext, c, w) } // handleJSONEncrypt processes a complete JSON mode encryption request, @@ -216,37 +254,16 @@ func handleJSONEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - // Extract and validate SPIFFE ID before accessing cipher - if authErr := net.AuthorizeAndRespondOnFail( - reqres.CipherEncryptResponse{}.Unauthorized(), - predicate.AllowSPIFFEIDForCipherEncrypt, - state.CheckPolicyAccess, + req, err := readAndGuardRequest( + readJSONEncryptRequestWithoutGuard, + guardCipherEncryptRequest, w, r, - ); authErr != nil { - return authErr - } - - // Parse request (doesn't need cipher) - request, jsonErr := readJSONEncryptRequestWithoutGuard(w, r) - if jsonErr != nil { - return jsonErr - } - - // Full guard validation (auth and request fields) - guardErr := guardCipherEncryptRequest(*request, w, r) - if guardErr != nil { - return guardErr - } - - // Get cipher only after auth passes - c, cipherErr := getCipher() - if cipherErr != nil { - return cipherErr + ) + if err != nil { + return err } - nonce, ciphertext, encryptErr := encryptDataJSON( - request.Plaintext, c, w, - ) + nonce, ciphertext, encryptErr := getCipherAndEncrypt(getCipher, net.EncryptDataJSON, req.Plaintext, w) if encryptErr != nil { return encryptErr } diff --git a/app/nexus/internal/route/cipher/read.go b/app/nexus/internal/route/cipher/read.go index 7734951f..a9ec3775 100644 --- a/app/nexus/internal/route/cipher/read.go +++ b/app/nexus/internal/route/cipher/read.go @@ -121,24 +121,26 @@ func readStreamingDecryptRequestData( } // readStreamingEncryptRequestWithoutGuard reads a streaming mode encryption -// request without performing guard validation. +// request without performing guard validation. The raw binary body is wrapped +// in a CipherEncryptRequest to provide a unified interface with the JSON +// reader. // // Parameters: // - w: The HTTP response writer for error responses // - r: The HTTP request containing the binary data // // Returns: -// - plaintext: The plaintext data to encrypt +// - *reqres.CipherEncryptRequest: The request with plaintext populated // - *sdkErrors.SDKError: An error if reading fails func readStreamingEncryptRequestWithoutGuard( w http.ResponseWriter, r *http.Request, -) ([]byte, *sdkErrors.SDKError) { +) (*reqres.CipherEncryptRequest, *sdkErrors.SDKError) { plaintext, err := net.ReadRequestBodyAndRespondOnFail(w, r) if err != nil { return nil, err } - return plaintext, nil + return &reqres.CipherEncryptRequest{Plaintext: plaintext}, nil } // readJSONEncryptRequestWithoutGuard reads and parses a JSON mode encryption diff --git a/app/nexus/internal/route/cipher/state.go b/app/nexus/internal/route/cipher/state.go index e7d4c7b3..66a1186b 100644 --- a/app/nexus/internal/route/cipher/state.go +++ b/app/nexus/internal/route/cipher/state.go @@ -3,67 +3,3 @@ // \\\\\\\ SPDX-License-Identifier: Apache-2.0 package cipher - -import ( - "crypto/cipher" - "net/http" - - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/net" - - "github.com/spiffe/spike/app/nexus/internal/state/persist" -) - -// getCipherOrFailStreaming retrieves the system cipher from the backend -// and handles errors for streaming mode requests. -// -// If the cipher is unavailable, sends a plain HTTP error response. -// -// Parameters: -// - w: The HTTP response writer for sending error responses -// -// Returns: -// - cipher.AEAD: The system cipher if available, nil otherwise -// - *sdkErrors.SDKError: An error if the cipher is unavailable, nil otherwise -func getCipherOrFailStreaming( - w http.ResponseWriter, -) (cipher.AEAD, *sdkErrors.SDKError) { - c := persist.Backend().GetCipher() - - if c == nil { - http.Error( - w, string(sdkErrors.ErrCryptoCipherNotAvailable.Code), - http.StatusInternalServerError, - ) - return nil, sdkErrors.ErrCryptoCipherNotAvailable.Clone() - } - - return c, nil -} - -// getCipherOrFailJSON retrieves the system cipher from the backend and -// handles errors for JSON mode requests. -// -// If the cipher is unavailable, sends a structured JSON error response. -// -// Parameters: -// - w: The HTTP response writer for sending error responses -// - errorResponse: The error response of type T to send as JSON -// -// Returns: -// - cipher.AEAD: The system cipher if available, nil otherwise -// - *sdkErrors.SDKError: An error if the cipher is unavailable, nil otherwise -func getCipherOrFailJSON[T any]( - w http.ResponseWriter, errorResponse T, -) (cipher.AEAD, *sdkErrors.SDKError) { - c := persist.Backend().GetCipher() - if c == nil { - failErr := net.Fail(errorResponse, w, http.StatusInternalServerError) - if failErr != nil { - return nil, sdkErrors.ErrCryptoCipherNotAvailable.Wrap(failErr) - } - return nil, sdkErrors.ErrCryptoCipherNotAvailable.Clone() - } - - return c, nil -} diff --git a/app/nexus/internal/route/operator/recover.go b/app/nexus/internal/route/operator/recover.go index 20576cbe..05daea9e 100644 --- a/app/nexus/internal/route/operator/recover.go +++ b/app/nexus/internal/route/operator/recover.go @@ -11,10 +11,10 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" "github.com/spiffe/spike-sdk-go/config/env" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/security/mem" - "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike/app/nexus/internal/initialization/recovery" ) diff --git a/app/nexus/internal/route/operator/recover_intercept.go b/app/nexus/internal/route/operator/recover_intercept.go index c12ac8ad..09e81c54 100644 --- a/app/nexus/internal/route/operator/recover_intercept.go +++ b/app/nexus/internal/route/operator/recover_intercept.go @@ -44,9 +44,13 @@ func guardRecoverRequest( ) *sdkErrors.SDKError { // No CheckAccess because this route is privileged and should not honor // policy overrides. Match exact SPIFFE ID instead. - return net.AuthorizeAndRespondOnFailNoPolicy( - reqres.PolicyPutResponse{}.Unauthorized(), + if authErr := net.AuthorizeAndRespondOnFailNoPolicy( + reqres.RecoverResponse{}.Unauthorized(), spiffeid.IsPilotRecover, w, r, - ) + ); authErr != nil { + return authErr + } + + return nil } diff --git a/app/nexus/internal/route/operator/restore.go b/app/nexus/internal/route/operator/restore.go index 2b282021..a1f04fb0 100644 --- a/app/nexus/internal/route/operator/restore.go +++ b/app/nexus/internal/route/operator/restore.go @@ -13,11 +13,11 @@ import ( "github.com/spiffe/spike-sdk-go/config/env" "github.com/spiffe/spike-sdk-go/crypto" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/log" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/security/mem" - "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike/app/nexus/internal/initialization/recovery" ) diff --git a/app/nexus/internal/route/secret/delete.go b/app/nexus/internal/route/secret/delete.go index d20abe3b..b6bf5784 100644 --- a/app/nexus/internal/route/secret/delete.go +++ b/app/nexus/internal/route/secret/delete.go @@ -11,7 +11,6 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - state "github.com/spiffe/spike/app/nexus/internal/state/base" ) diff --git a/app/nexus/internal/state/backend/interface.go b/app/nexus/internal/state/backend/interface.go index 4405bc98..8b861ab4 100644 --- a/app/nexus/internal/state/backend/interface.go +++ b/app/nexus/internal/state/backend/interface.go @@ -213,6 +213,12 @@ type Backend interface { // methods which handle encryption internally, because Backend implementations // may return nil if cipher access is not appropriate for their specific use // case. + // + // IMPLEMENTATION NOTE: All implementations must include a nil receiver guard. + // This method may be passed as a method value (e.g., `backend.GetCipher` + // without parentheses), binding the receiver at capture time. If the backend + // becomes nil before invocation, the guard returns nil instead of panicking. + // Callers must check for a nil return value. GetCipher() cipher.AEAD } diff --git a/app/nexus/internal/state/backend/lite/initialize.go b/app/nexus/internal/state/backend/lite/initialize.go index 831f63f6..38190707 100644 --- a/app/nexus/internal/state/backend/lite/initialize.go +++ b/app/nexus/internal/state/backend/lite/initialize.go @@ -69,9 +69,19 @@ func New(rootKey *[crypto.AES256KeySize]byte) ( // This method provides access to the underlying AEAD (Authenticated Encryption // with Associated Data) cipher for performing cryptographic operations. // +// This method includes a nil receiver guard because it may be passed as a +// method value (e.g., `backend.GetCipher` without parentheses) where the +// receiver is bound at capture time. If the backend is nil when the method +// value is later invoked, the guard prevents a panic by returning nil +// instead of dereferencing a nil pointer. Callers should check for a nil +// return value. +// // Returns: // - cipher.AEAD: The AES-GCM cipher instance configured during store -// initialization +// initialization, or nil if the receiver is nil func (ds *Store) GetCipher() cipher.AEAD { + if ds == nil { + return nil + } return ds.Cipher } diff --git a/app/nexus/internal/state/backend/memory/memory.go b/app/nexus/internal/state/backend/memory/memory.go index 6d0d1613..234568dd 100644 --- a/app/nexus/internal/state/backend/memory/memory.go +++ b/app/nexus/internal/state/backend/memory/memory.go @@ -297,8 +297,19 @@ func (s *Store) DeletePolicy(_ context.Context, id string) *sdkErrors.SDKError { // compatibility but is not used for encryption since data is kept // in memory in plaintext. // +// This method includes a nil receiver guard because it may be passed as a +// method value (e.g., `backend.GetCipher` without parentheses) where the +// receiver is bound at capture time. If the backend is nil when the method +// value is later invoked, the guard prevents a panic by returning nil +// instead of dereferencing a nil pointer. Callers should check for a nil +// return value. +// // Returns: -// - cipher.AEAD: The cipher provided during initialization +// - cipher.AEAD: The cipher provided during initialization, or nil if the +// receiver is nil func (s *Store) GetCipher() cipher.AEAD { + if s == nil { + return nil + } return s.cipher } diff --git a/app/nexus/internal/state/backend/noop/noop.go b/app/nexus/internal/state/backend/noop/noop.go index f9b58541..fed8a811 100644 --- a/app/nexus/internal/state/backend/noop/noop.go +++ b/app/nexus/internal/state/backend/noop/noop.go @@ -174,8 +174,18 @@ func (s *Store) DeletePolicy(_ context.Context, _ string) *sdkErrors.SDKError { // satisfy the backend interface but provides no cipher since no actual // encryption operations are performed. // +// This method includes a nil receiver guard for consistency with other +// backend implementations. The guard exists because GetCipher may be passed +// as a method value (e.g., `backend.GetCipher` without parentheses) where +// the receiver is bound at capture time. If the backend is nil when the +// method value is later invoked, the guard prevents a panic. For this no-op +// implementation, the result is the same (nil) regardless of receiver state. +// // Returns: // - cipher.AEAD: Always returns nil func (s *Store) GetCipher() cipher.AEAD { + if s == nil { + return nil + } return nil } diff --git a/app/nexus/internal/state/backend/sqlite/persist/cipher.go b/app/nexus/internal/state/backend/sqlite/persist/cipher.go index e765ba7b..850da02f 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/cipher.go +++ b/app/nexus/internal/state/backend/sqlite/persist/cipher.go @@ -10,9 +10,19 @@ import "crypto/cipher" // decrypting secrets stored in the database. The cipher is initialized when // the DataStore is created and remains constant throughout its lifetime. // +// This method includes a nil receiver guard because it may be passed as a +// method value (e.g., `backend.GetCipher` without parentheses) where the +// receiver is bound at capture time. If the backend is nil when the method +// value is later invoked, the guard prevents a panic by returning nil +// instead of dereferencing a nil pointer. Callers should check for a nil +// return value. +// // Returns: // - cipher.AEAD: The authenticated encryption with associated data cipher -// instance used for secret encryption and decryption operations. +// instance, or nil if the receiver is nil func (s *DataStore) GetCipher() cipher.AEAD { + if s == nil { + return nil + } return s.Cipher } diff --git a/app/nexus/internal/state/backend/sqlite/persist/doc.go b/app/nexus/internal/state/backend/sqlite/persist/doc.go index 442d06de..fa5c51fb 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/doc.go +++ b/app/nexus/internal/state/backend/sqlite/persist/doc.go @@ -24,7 +24,6 @@ // - initialize.go: Database initialization and schema setup // - secret.go, secret_load.go: Secret storage and retrieval // - policy.go: Policy storage and retrieval -// - crypto.go, cipher.go, nonce.go: Encryption utilities // - transform.go: Data serialization/deserialization // - schema.go: Database schema definitions // - options.go, parse.go: Configuration parsing diff --git a/go.mod b/go.mod index 8eb146e1..bbb6a380 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.18.2 + github.com/spiffe/spike-sdk-go v0.18.5 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index a4dbca34..04ffc28a 100644 --- a/go.sum +++ b/go.sum @@ -126,9 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.18.1 h1:f/E7GSoGwJasj85iwT1/1t+A5FJaFRBGvivGqdApb8g= -github.com/spiffe/spike-sdk-go v0.18.1/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= -github.com/spiffe/spike-sdk-go v0.18.2/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.18.5 h1:N0hw5Wlo+8M+s7BZTIpsdcmobdKJ+lFbDzKPX9wuEFk= +github.com/spiffe/spike-sdk-go v0.18.5/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 75f6a82625bca41221c1265c90ed9bc09edaf2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Mon, 19 Jan 2026 03:02:50 +0000 Subject: [PATCH 24/28] Update to spike-sdk-go v0.19.1 with context.Context support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add context.Context parameter to all SDK API calls to enable proper request cancellation and timeout handling. This replaces the previous workaround in dispatch.go that wrapped calls in goroutines. Key changes: - All API method calls now accept context as first parameter - Remove contributeWithContext wrapper in favor of native SDK support - Move cipher helper functions (readAndGuardRequest, getCipherAndEncrypt, respond* functions) to SDK net package - Use AuthorizeAndRespondOnFailForPath for path-based authorization - Delete unused cipher state, net, and read files now handled by SDK - Remove toSecretMetadataSuccessResponse in favor of SDK's ValueToSecretMetadataSuccessResponse Signed-off-by: Volkan Özçelik --- app/bootstrap/internal/net/broadcast.go | 2 +- app/bootstrap/internal/net/dispatch.go | 66 ++----- app/demo/cmd/main.go | 7 +- app/keeper/internal/route/store/shard.go | 1 + .../internal/initialization/recovery/shard.go | 5 +- .../initialization/recovery/update.go | 5 +- .../route/cipher/encrypt_intercept.go | 17 -- app/nexus/internal/route/cipher/handle.go | 96 ++-------- app/nexus/internal/route/cipher/net.go | 105 ----------- app/nexus/internal/route/cipher/read.go | 174 ------------------ app/nexus/internal/route/cipher/state.go | 5 - .../internal/route/secret/delete_intercept.go | 15 +- .../internal/route/secret/get_intercept.go | 11 +- app/nexus/internal/route/secret/map.go | 57 ------ .../internal/route/secret/metadata_get.go | 4 +- app/spike/internal/cmd/cipher/decrypt_impl.go | 9 +- app/spike/internal/cmd/cipher/encrypt_impl.go | 9 +- app/spike/internal/cmd/operator/recover.go | 5 +- app/spike/internal/cmd/operator/restore.go | 5 +- app/spike/internal/cmd/policy/apply.go | 6 +- app/spike/internal/cmd/policy/create.go | 6 +- app/spike/internal/cmd/policy/delete.go | 5 +- app/spike/internal/cmd/policy/filter.go | 5 +- app/spike/internal/cmd/policy/get.go | 6 +- app/spike/internal/cmd/policy/list.go | 6 +- app/spike/internal/cmd/policy/validation.go | 6 +- app/spike/internal/cmd/secret/delete.go | 7 +- app/spike/internal/cmd/secret/get.go | 5 +- app/spike/internal/cmd/secret/list.go | 6 +- app/spike/internal/cmd/secret/metadata_get.go | 6 +- app/spike/internal/cmd/secret/put.go | 5 +- app/spike/internal/cmd/secret/undelete.go | 5 +- go.mod | 2 +- go.sum | 4 +- 34 files changed, 140 insertions(+), 538 deletions(-) delete mode 100644 app/nexus/internal/route/cipher/net.go delete mode 100644 app/nexus/internal/route/cipher/read.go delete mode 100644 app/nexus/internal/route/cipher/state.go delete mode 100644 app/nexus/internal/route/secret/map.go diff --git a/app/bootstrap/internal/net/broadcast.go b/app/bootstrap/internal/net/broadcast.go index 95f0f299..c59987e2 100644 --- a/app/bootstrap/internal/net/broadcast.go +++ b/app/bootstrap/internal/net/broadcast.go @@ -152,7 +152,7 @@ func VerifyInitialization(ctx context.Context, api *spike.API) { // give up if we cannot verify the initialization in a timely manner. _, retryErr := retry.Do(ctx, func() (bool, *sdkErrors.SDKError) { - verifyErr := api.Verify(randomText, nonce, ciphertext) + verifyErr := api.Verify(ctx, randomText, nonce, ciphertext) if verifyErr != nil { failErr := sdkErrors.ErrCryptoCipherVerificationFailed.Wrap(verifyErr) failErr.Msg = "failed to verify initialization: will retry" diff --git a/app/bootstrap/internal/net/dispatch.go b/app/bootstrap/internal/net/dispatch.go index 116e9a9d..edf3bb30 100644 --- a/app/bootstrap/internal/net/dispatch.go +++ b/app/bootstrap/internal/net/dispatch.go @@ -18,55 +18,6 @@ import ( "github.com/spiffe/spike/app/bootstrap/internal/state" ) -// contributeWithContext wraps api.Contribute so cancellation/timeouts can be -// enforced. The call is executed in a goroutine, and the function waits for -// either the contribution result or ctx.Done, returning ctx.Err() when the -// context ends first. -// -// IMPORTANT: This is a workaround, not a proper fix. When the context times -// out, this function returns early, but the underlying api.Contribute call -// continues running in the background. This means: -// - The HTTP request is NOT actually canceled -// - On retries, multiple concurrent requests may be in flight to the same -// keeper -// - Resources (goroutines, connections) are leaked until the orphaned calls -// complete -// -// The proper fix is to update the SDK so that api.Contribute accepts a -// context.Context parameter and uses http.NewRequestWithContext internally. -// This would allow the HTTP client to respect cancellation and timeouts. -// -// Parameters: -// - ctx: Context that controls cancellation/deadline for the contribution -// - api: SPIKE API client used to send the share -// - share: Keeper share being contributed -// - keeperID: Identifier of the target keeper -func contributeWithContext( - ctx context.Context, api *spike.API, - share secretsharing.Share, keeperID string, -) error { - done := make(chan error, 1) - - go func() { - contributeErr := api.Contribute(share, keeperID) - // Explicitly send nil to avoid the nil-interface-with-nil-pointer issue. - // If we send a nil *SDKError directly, it becomes a non-nil error interface - // holding a nil pointer, causing "err != nil" checks to incorrectly pass. - if contributeErr == nil { - done <- nil - return - } - done <- contributeErr - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - // broadcastToKeeper sends a root key share to a single SPIKE Keeper instance. // It creates a timeout context for the operation and retries with exponential // backoff until success or the maximum retry attempts are exhausted. @@ -91,7 +42,14 @@ func broadcastToKeeper( keeperShare := state.KeeperShare(rs, keeperID) - keeperCtx, cancel := context.WithTimeout(ctx, timeout) + // A zero timeout means no timeout (infinite). We must handle this explicitly + // because context.WithTimeout(ctx, 0) creates an already-expired context, + // not an infinite one. + keeperCtx := ctx + var cancel context.CancelFunc = func() {} + if timeout > 0 { + keeperCtx, cancel = context.WithTimeout(ctx, timeout) + } defer cancel() _, err := retry.WithMaxAttempts(keeperCtx, maxRetries, @@ -102,15 +60,15 @@ func broadcastToKeeper( "keeper_url", keeperURL, ) - contributeErr := contributeWithContext( - keeperCtx, api, keeperShare, keeperID, - ) - if contributeErr != nil { + if contributeErr := api.Contribute( + keeperCtx, keeperShare, keeperID, + ); contributeErr != nil { warnErr := sdkErrors.ErrAPIPostFailed.Wrap(contributeErr) warnErr.Msg = "failed to send shard: will retry" log.WarnErr(fName, *warnErr) return false, warnErr } + return true, nil }, retry.WithBackOffOptions( diff --git a/app/demo/cmd/main.go b/app/demo/cmd/main.go index 88ab47e1..2e4c4eb8 100644 --- a/app/demo/cmd/main.go +++ b/app/demo/cmd/main.go @@ -5,6 +5,7 @@ package main import ( + "context" "fmt" spike "github.com/spiffe/spike-sdk-go/api" @@ -38,9 +39,11 @@ func main() { // The path to store/retrieve/update the secret. path := "tenants/demo/db/creds" + ctx := context.Background() + // Create a Secret // https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#PutSecret - putErr := api.PutSecret(path, map[string]string{ + putErr := api.PutSecret(ctx, path, map[string]string{ "username": "SPIKE", "password": "SPIKE_Rocks", }) @@ -51,7 +54,7 @@ func main() { // Read the Secret // https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#GetSecret - secret, getErr := api.GetSecret(path) + secret, getErr := api.GetSecret(ctx, path) if getErr != nil { fmt.Println("Error reading secret:", getErr.Error()) return diff --git a/app/keeper/internal/route/store/shard.go b/app/keeper/internal/route/store/shard.go index c0d07ce5..7fef8ede 100644 --- a/app/keeper/internal/route/store/shard.go +++ b/app/keeper/internal/route/store/shard.go @@ -81,6 +81,7 @@ func RouteShard( return sdkErrors.ErrDataInvalidInput } + // Capture respond body to securely wipe out. responseBody, respondErr := net.SuccessWithResponseBody( reqres.ShardGetResponse{Shard: sh}.Success(), w, ) diff --git a/app/nexus/internal/initialization/recovery/shard.go b/app/nexus/internal/initialization/recovery/shard.go index 7b567c76..194ad859 100644 --- a/app/nexus/internal/initialization/recovery/shard.go +++ b/app/nexus/internal/initialization/recovery/shard.go @@ -5,6 +5,7 @@ package recovery import ( + "context" "encoding/json" "github.com/spiffe/go-spiffe/v2/workloadapi" @@ -54,7 +55,9 @@ func shardGetResponse( predicate.AllowKeeper, ) - data, postErr := net.Post(client, u, md) + ctx := context.Background() + + data, postErr := net.Post(ctx, client, u, md) if postErr != nil { return nil, postErr } diff --git a/app/nexus/internal/initialization/recovery/update.go b/app/nexus/internal/initialization/recovery/update.go index a6ac96c6..9cc90b63 100644 --- a/app/nexus/internal/initialization/recovery/update.go +++ b/app/nexus/internal/initialization/recovery/update.go @@ -5,6 +5,7 @@ package recovery import ( + "context" "encoding/json" "net/url" "strconv" @@ -162,7 +163,9 @@ func sendShardsToKeepers( source, predicate.AllowKeeper, ) - _, postErr := net.Post(client, u, md) + ctx := context.Background() + + _, postErr := net.Post(ctx, client, u, md) // Security: Ensure that md is zeroed out. mem.ClearBytes(md) diff --git a/app/nexus/internal/route/cipher/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index 6f570704..39877a98 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -14,23 +14,6 @@ import ( state "github.com/spiffe/spike/app/nexus/internal/state/base" ) -//func spiffeidAllowedForEncryptCipher(spiffeid string) bool { -// // Lite Workloads are always allowed: -// allowed := false -// if sdkSpiffeid.IsLiteWorkload(spiffeid) { -// allowed = true -// } -// // If not, do a policy check to determine if the request is allowed: -// if !allowed { -// allowed = state.CheckPolicyAccess( -// spiffeid, -// apiAuth.PathSystemCipherExecute, -// []data.PolicyPermission{data.PermissionExecute}, -// ) -// } -// return allowed -//} - // guardCipherEncryptRequest validates a cipher encryption request by // performing authentication, authorization, and request field validation. // diff --git a/app/nexus/internal/route/cipher/handle.go b/app/nexus/internal/route/cipher/handle.go index 2e5f589a..7d99331e 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -35,9 +35,9 @@ func handleStreamingDecrypt( getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { // NOTE: since we are dealing with streaming data, we cannot directly use - // the request parameter validation patterns that we employ in the JSON/REST - // payloads. We need to read the entire stream and generate a request - // entity accordingly. + // the request parameter validation patterns (i.e. the guard functions) + // that we employ in the JSON/REST payloads. We need to read the entire + // stream and generate a request entity accordingly. if authErr := net.AuthorizeAndRespondOnFail( reqres.CipherDecryptResponse{}.Unauthorized(), @@ -55,7 +55,7 @@ func handleStreamingDecrypt( } // Read request data (now that we have cipher for nonce size) - version, nonce, ciphertext, readErr := readStreamingDecryptRequestData( + version, nonce, ciphertext, readErr := net.ReadStreamingDecryptRequestData( w, r, c, ) if readErr != nil { @@ -80,7 +80,7 @@ func handleStreamingDecrypt( return decryptErr } - return respondStreamingDecrypt(plaintext, w) + return net.RespondStreamingDecrypt(plaintext, w) } // handleJSONDecrypt processes a complete JSON mode decryption request, @@ -112,7 +112,7 @@ func handleJSONDecrypt( } // Parse request (doesn't need cipher) - request, readErr := readJSONDecryptRequestWithoutGuard(w, r) + request, readErr := net.ReadJSONDecryptRequestWithoutGuard(w, r) if readErr != nil { return readErr } @@ -136,7 +136,7 @@ func handleJSONDecrypt( return decryptErr } - return respondJSONDecrypt(plaintext, w) + return net.RespondJSONDecrypt(plaintext, w) } // handleStreamingEncrypt processes a complete streaming mode encryption @@ -157,8 +157,8 @@ func handleStreamingEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - req, err := readAndGuardRequest( - readStreamingEncryptRequestWithoutGuard, + req, err := net.ReadAndGuardRequest( + net.ReadStreamingEncryptRequestWithoutGuard, guardCipherEncryptRequest, w, r, ) @@ -166,74 +166,14 @@ func handleStreamingEncrypt( return err } - nonce, ciphertext, encryptErr := getCipherAndEncrypt(getCipher, net.EncryptDataStreaming, req.Plaintext, w) + nonce, ciphertext, encryptErr := net.GetCipherAndEncrypt( + getCipher, net.EncryptDataStreaming, req.Plaintext, w, + ) if encryptErr != nil { return encryptErr } - return respondStreamingEncrypt(nonce, ciphertext, w) -} - -type Handler[T any] func(w http.ResponseWriter, r *http.Request) (*T, *sdkErrors.SDKError) -type HandlerWithEntity[T any] func(req T, w http.ResponseWriter, r *http.Request) *sdkErrors.SDKError - -type Encryptor func(plaintext []byte, c cipher.AEAD, w http.ResponseWriter) ([]byte, []byte, *sdkErrors.SDKError) - -// readAndGuardRequest reads and parses a request, then validates it using the -// provided guard function. This is similar to net.ReadParseAndGuard but accepts -// a custom reader function for streaming mode support. -// -// Parameters: -// - readRequest: Function to read and parse the request body -// - guard: Function to validate the parsed request (handles auth and fields) -// - w: The HTTP response writer -// - r: The HTTP request -// -// Returns: -// - *T: The parsed and validated request -// - *sdkErrors.SDKError: An error if reading or validation fails -func readAndGuardRequest[T any]( - readRequest Handler[T], - guard HandlerWithEntity[T], - w http.ResponseWriter, r *http.Request, -) (*T, *sdkErrors.SDKError) { - request, readErr := readRequest(w, r) - if readErr != nil { - return nil, readErr - } - - if guardErr := guard(*request, w, r); guardErr != nil { - return nil, guardErr - } - - return request, nil -} - -// getCipherAndEncrypt retrieves the cipher and encrypts the provided data. -// This combines cipher acquisition and encryption into a single operation. -// -// Parameters: -// - getCipher: Function to retrieve the AEAD cipher -// - encryptData: The encryption function to use -// - plaintext: The data to encrypt -// - w: The HTTP response writer for error responses -// -// Returns: -// - []byte: The generated nonce -// - []byte: The encrypted ciphertext -// - *sdkErrors.SDKError: An error if cipher retrieval or encryption fails -func getCipherAndEncrypt( - getCipher func() (cipher.AEAD, *sdkErrors.SDKError), - encryptData Encryptor, - plaintext []byte, - w http.ResponseWriter, -) ([]byte, []byte, *sdkErrors.SDKError) { - c, cipherErr := getCipher() - if cipherErr != nil { - return nil, nil, cipherErr - } - - return encryptData(plaintext, c, w) + return net.RespondStreamingEncrypt(nonce, ciphertext, w) } // handleJSONEncrypt processes a complete JSON mode encryption request, @@ -254,8 +194,8 @@ func handleJSONEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - req, err := readAndGuardRequest( - readJSONEncryptRequestWithoutGuard, + req, err := net.ReadAndGuardRequest( + net.ReadJSONEncryptRequestWithoutGuard, guardCipherEncryptRequest, w, r, ) @@ -263,10 +203,12 @@ func handleJSONEncrypt( return err } - nonce, ciphertext, encryptErr := getCipherAndEncrypt(getCipher, net.EncryptDataJSON, req.Plaintext, w) + nonce, ciphertext, encryptErr := net.GetCipherAndEncrypt( + getCipher, net.EncryptDataJSON, req.Plaintext, w, + ) if encryptErr != nil { return encryptErr } - return respondJSONEncrypt(nonce, ciphertext, w) + return net.RespondJSONEncrypt(nonce, ciphertext, w) } diff --git a/app/nexus/internal/route/cipher/net.go b/app/nexus/internal/route/cipher/net.go deleted file mode 100644 index b4a48970..00000000 --- a/app/nexus/internal/route/cipher/net.go +++ /dev/null @@ -1,105 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher - -import ( - "net/http" - - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/net" -) - -// respondStreamingDecrypt sends the decrypted plaintext as raw binary data -// for streaming mode requests. -// -// Parameters: -// - plaintext: The decrypted data to send -// - w: The HTTP response writer -// -// Returns: -// - *sdkErrors.SDKError: An error if the response fails to send, nil on -// success -func respondStreamingDecrypt( - plaintext []byte, w http.ResponseWriter, -) *sdkErrors.SDKError { - w.Header().Set(headerKeyContentType, headerValueOctetStream) - if _, err := w.Write(plaintext); err != nil { - return sdkErrors.ErrFSStreamWriteFailed.Wrap(err) - } - return nil -} - -// respondJSONDecrypt sends the decrypted plaintext as a structured JSON -// response for JSON mode requests. -// -// Parameters: -// - plaintext: The decrypted data to send -// - w: The HTTP response writer -// -// Returns: -// - *sdkErrors.SDKError: nil on success, or an error if the response fails -// to send -func respondJSONDecrypt( - plaintext []byte, w http.ResponseWriter, -) *sdkErrors.SDKError { - return net.Success( - reqres.CipherDecryptResponse{ - Plaintext: plaintext, - }.Success(), w, - ) -} - -// respondStreamingEncrypt sends the encrypted ciphertext as raw binary data -// for streaming mode requests. -// -// The streaming format is: version byte + nonce + ciphertext -// -// Parameters: -// - nonce: The nonce bytes -// - ciphertext: The encrypted data to send -// - w: The HTTP response writer -// -// Returns: -// - *sdkErrors.SDKError: An error if the response fails to send, nil on -// success -func respondStreamingEncrypt( - nonce, ciphertext []byte, w http.ResponseWriter, -) *sdkErrors.SDKError { - w.Header().Set(headerKeyContentType, headerValueOctetStream) - if _, err := w.Write([]byte{spikeCipherVersion}); err != nil { - return sdkErrors.ErrFSStreamWriteFailed.Wrap(err) - } - if _, err := w.Write(nonce); err != nil { - return sdkErrors.ErrFSStreamWriteFailed.Wrap(err) - } - if _, err := w.Write(ciphertext); err != nil { - return sdkErrors.ErrFSStreamWriteFailed.Wrap(err) - } - return nil -} - -// respondJSONEncrypt sends the encrypted ciphertext as a structured JSON -// response for JSON mode requests. -// -// Parameters: -// - nonce: The nonce bytes -// - ciphertext: The encrypted data to send -// - w: The HTTP response writer -// -// Returns: -// - *sdkErrors.SDKError: nil on success, or an error if the response fails -// to send -func respondJSONEncrypt( - nonce, ciphertext []byte, w http.ResponseWriter, -) *sdkErrors.SDKError { - return net.Success( - reqres.CipherEncryptResponse{ - Version: spikeCipherVersion, - Nonce: nonce, - Ciphertext: ciphertext, - }.Success(), w, - ) -} diff --git a/app/nexus/internal/route/cipher/read.go b/app/nexus/internal/route/cipher/read.go deleted file mode 100644 index a9ec3775..00000000 --- a/app/nexus/internal/route/cipher/read.go +++ /dev/null @@ -1,174 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher - -import ( - "crypto/cipher" - "io" - "net/http" - - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/net" -) - -// readJSONDecryptRequestWithoutGuard reads and parses a JSON mode decryption -// request without performing guard validation. -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the JSON data -// -// Returns: -// - *reqres.CipherDecryptRequest: The parsed request -// - *sdkErrors.SDKError: An error if reading or parsing fails -func readJSONDecryptRequestWithoutGuard( - w http.ResponseWriter, r *http.Request, -) (*reqres.CipherDecryptRequest, *sdkErrors.SDKError) { - requestBody, err := net.ReadRequestBodyAndRespondOnFail(w, r) - if err != nil { - return nil, err - } - - request, unmarshalErr := net.UnmarshalAndRespondOnFail[ - reqres.CipherDecryptRequest, reqres.CipherDecryptResponse]( - requestBody, w, - reqres.CipherDecryptResponse{}.BadRequest(), - ) - if unmarshalErr != nil { - return nil, unmarshalErr - } - - return request, nil -} - -// readStreamingDecryptRequestData reads the binary data from a streaming mode -// decryption request (version, nonce, ciphertext). -// -// This function does NOT perform authentication - the caller must have already -// called the guard function. -// -// The streaming format is: version byte + nonce and ciphertext -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the binary data -// - c: The cipher to determine nonce size -// -// Returns: -// - version: The protocol version byte -// - nonce: The nonce bytes -// - ciphertext: The encrypted data -// - *sdkErrors.SDKError: An error if reading fails -func readStreamingDecryptRequestData( - w http.ResponseWriter, r *http.Request, c cipher.AEAD, -) (byte, []byte, []byte, *sdkErrors.SDKError) { - const fName = "readStreamingDecryptRequestData" - - // Read the version byte - ver := make([]byte, 1) - n, err := io.ReadFull(r.Body, ver) - if err != nil || n != 1 { - failErr := sdkErrors.ErrCryptoFailedToReadVersion.Clone() - log.WarnErr(fName, *failErr) - http.Error( - w, string(failErr.Code), http.StatusBadRequest, - ) - return 0, nil, nil, failErr - } - - version := ver[0] - - // Validate version matches the expected value - if version != spikeCipherVersion { - failErr := sdkErrors.ErrCryptoUnsupportedCipherVersion.Clone() - log.WarnErr(fName, *failErr) - http.Error( - w, string(failErr.Code), http.StatusBadRequest, - ) - return 0, nil, nil, failErr - } - - // Read the nonce - bytesToRead := c.NonceSize() - nonce := make([]byte, bytesToRead) - n, err = io.ReadFull(r.Body, nonce) - if err != nil || n != bytesToRead { - failErr := sdkErrors.ErrCryptoFailedToReadNonce.Clone() - log.WarnErr(fName, *failErr) - http.Error( - w, string(failErr.Code), http.StatusBadRequest, - ) - return 0, nil, nil, failErr - } - - // Read the remaining body as ciphertext - ciphertext, readErr := io.ReadAll(r.Body) - if readErr != nil { - failErr := sdkErrors.ErrDataReadFailure.Wrap(readErr) - failErr.Msg = "failed to read ciphertext" - log.WarnErr(fName, *failErr) - http.Error( - w, string(failErr.Code), http.StatusBadRequest, - ) - return 0, nil, nil, failErr - } - - return version, nonce, ciphertext, nil -} - -// readStreamingEncryptRequestWithoutGuard reads a streaming mode encryption -// request without performing guard validation. The raw binary body is wrapped -// in a CipherEncryptRequest to provide a unified interface with the JSON -// reader. -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the binary data -// -// Returns: -// - *reqres.CipherEncryptRequest: The request with plaintext populated -// - *sdkErrors.SDKError: An error if reading fails -func readStreamingEncryptRequestWithoutGuard( - w http.ResponseWriter, r *http.Request, -) (*reqres.CipherEncryptRequest, *sdkErrors.SDKError) { - plaintext, err := net.ReadRequestBodyAndRespondOnFail(w, r) - if err != nil { - return nil, err - } - - return &reqres.CipherEncryptRequest{Plaintext: plaintext}, nil -} - -// readJSONEncryptRequestWithoutGuard reads and parses a JSON mode encryption -// request without performing guard validation. -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the JSON data -// -// Returns: -// - *reqres.CipherEncryptRequest: The parsed request -// - *sdkErrors.SDKError: An error if reading or parsing fails -func readJSONEncryptRequestWithoutGuard( - w http.ResponseWriter, r *http.Request, -) (*reqres.CipherEncryptRequest, *sdkErrors.SDKError) { - requestBody, err := net.ReadRequestBodyAndRespondOnFail(w, r) - if err != nil { - return nil, err - } - - request, unmarshalErr := net.UnmarshalAndRespondOnFail[ - reqres.CipherEncryptRequest, reqres.CipherEncryptResponse]( - requestBody, w, - reqres.CipherEncryptResponse{}.BadRequest(), - ) - if unmarshalErr != nil { - return nil, unmarshalErr - } - - return request, nil -} diff --git a/app/nexus/internal/route/cipher/state.go b/app/nexus/internal/route/cipher/state.go deleted file mode 100644 index 66a1186b..00000000 --- a/app/nexus/internal/route/cipher/state.go +++ /dev/null @@ -1,5 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher diff --git a/app/nexus/internal/route/secret/delete_intercept.go b/app/nexus/internal/route/secret/delete_intercept.go index d156473a..b3f75f49 100644 --- a/app/nexus/internal/route/secret/delete_intercept.go +++ b/app/nexus/internal/route/secret/delete_intercept.go @@ -11,6 +11,7 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -39,18 +40,10 @@ import ( func guardDeleteSecretRequest( request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - if authErr := net.AuthorizeAndRespondOnFail( // TODO: AuthorizeAndRespondOnFailForPath(peerSPIFFEID, path, checkAccess) + if authErr := net.AuthorizeAndRespondOnFailForPath( reqres.SecretDeleteResponse{}.Unauthorized(), - - // TODO: type ForPathWithPolicyAccessChecker func(string peerSPIFFEID, string path, PolicyAccessChecker) bool - - func( - peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, - ) bool { - return predicate.AllowSPIFFEIDForSecretDelete( - peerSPIFFEID, request.Path, checkAccess, - ) - }, + request.Path, + predicate.AllowSPIFFEIDForSecretDelete, state.CheckPolicyAccess, w, r, ); authErr != nil { diff --git a/app/nexus/internal/route/secret/get_intercept.go b/app/nexus/internal/route/secret/get_intercept.go index 204d46ee..eff3a2b2 100644 --- a/app/nexus/internal/route/secret/get_intercept.go +++ b/app/nexus/internal/route/secret/get_intercept.go @@ -40,15 +40,10 @@ import ( func guardGetSecretRequest( request reqres.SecretGetRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - if authErr := net.AuthorizeAndRespondOnFail( + if authErr := net.AuthorizeAndRespondOnFailForPath( reqres.SecretGetResponse{}.Unauthorized(), - func( - peerSPIFFEID string, checkAccess predicate.PolicyAccessChecker, - ) bool { - return predicate.AllowSPIFFEIDForSecretRead( - peerSPIFFEID, request.Path, checkAccess, - ) - }, + request.Path, + predicate.AllowSPIFFEIDForSecretRead, state.CheckPolicyAccess, w, r, ); authErr != nil { diff --git a/app/nexus/internal/route/secret/map.go b/app/nexus/internal/route/secret/map.go deleted file mode 100644 index 50246107..00000000 --- a/app/nexus/internal/route/secret/map.go +++ /dev/null @@ -1,57 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package secret - -import ( - "github.com/spiffe/spike-sdk-go/api/entity/data" - "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" - "github.com/spiffe/spike-sdk-go/kv" -) - -// toSecretMetadataSuccessResponse converts a key-value store secret value into -// a secret metadata response. -// -// The function transforms the internal kv.Value representation into the API -// response format by: -// - Converting all secret versions into a map of version info -// - Extracting metadata including current/oldest versions and timestamps -// - Preserving version-specific details like creation and deletion times -// -// This conversion is used when clients request secret metadata without -// retrieving the actual secret data, allowing them to inspect version history -// and lifecycle information. -// -// Parameters: -// - secret: The key-value store secret value containing version history -// and metadata -// -// Returns: -// - reqres.SecretMetadataResponse: The formatted metadata response containing -// version information and metadata suitable for API responses -func toSecretMetadataSuccessResponse( - secret *kv.Value, -) reqres.SecretMetadataResponse { - versions := make(map[int]data.SecretVersionInfo) - for _, version := range secret.Versions { - versions[version.Version] = data.SecretVersionInfo{ - CreatedTime: version.CreatedTime, - Version: version.Version, - DeletedTime: version.DeletedTime, - } - } - - return reqres.SecretMetadataResponse{ - SecretMetadata: data.SecretMetadata{ - Versions: versions, - Metadata: data.SecretMetaDataContent{ - CurrentVersion: secret.Metadata.CurrentVersion, - OldestVersion: secret.Metadata.OldestVersion, - CreatedTime: secret.Metadata.CreatedTime, - UpdatedTime: secret.Metadata.UpdatedTime, - MaxVersions: secret.Metadata.MaxVersions, - }, - }, - }.Success() -} diff --git a/app/nexus/internal/route/secret/metadata_get.go b/app/nexus/internal/route/secret/metadata_get.go index 05956df5..96e3bbc8 100644 --- a/app/nexus/internal/route/secret/metadata_get.go +++ b/app/nexus/internal/route/secret/metadata_get.go @@ -9,9 +9,9 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/journal" "github.com/spiffe/spike-sdk-go/net" - "github.com/spiffe/spike-sdk-go/journal" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -101,5 +101,5 @@ func RouteGetSecretMetadata( return net.RespondWithHTTPError(getErr, w, reqres.SecretMetadataResponse{}) } - return net.Success(toSecretMetadataSuccessResponse(rawSecret), w) + return net.Success(reqres.ValueToSecretMetadataSuccessResponse(rawSecret), w) } diff --git a/app/spike/internal/cmd/cipher/decrypt_impl.go b/app/spike/internal/cmd/cipher/decrypt_impl.go index 97944308..b3745fdb 100644 --- a/app/spike/internal/cmd/cipher/decrypt_impl.go +++ b/app/spike/internal/cmd/cipher/decrypt_impl.go @@ -5,6 +5,7 @@ package cipher import ( + "context" "encoding/base64" "os" "strconv" @@ -53,7 +54,9 @@ func decryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) { } defer cleanupOut() - plaintext, apiErr := api.CipherDecryptStream(in) + ctx := context.Background() + + plaintext, apiErr := api.CipherDecryptStream(ctx, in) if stdout.HandleAPIError(cmd, apiErr) { return } @@ -107,8 +110,10 @@ func decryptJSON(cmd *cobra.Command, api *sdk.API, versionStr, nonceB64, } defer cleanupOut() + ctx := context.Background() + plaintext, apiErr := api.CipherDecrypt( - byte(v), nonce, ciphertext, algorithm, + ctx, byte(v), nonce, ciphertext, algorithm, ) if stdout.HandleAPIError(cmd, apiErr) { return diff --git a/app/spike/internal/cmd/cipher/encrypt_impl.go b/app/spike/internal/cmd/cipher/encrypt_impl.go index 01584078..78b7e608 100644 --- a/app/spike/internal/cmd/cipher/encrypt_impl.go +++ b/app/spike/internal/cmd/cipher/encrypt_impl.go @@ -5,6 +5,7 @@ package cipher import ( + "context" "encoding/base64" "os" @@ -52,7 +53,9 @@ func encryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) { } defer cleanupOut() - ciphertext, apiErr := api.CipherEncryptStream(in) + ctx := context.Background() + + ciphertext, apiErr := api.CipherEncryptStream(ctx, in) if stdout.HandleAPIError(cmd, apiErr) { return } @@ -90,7 +93,9 @@ func encryptJSON(cmd *cobra.Command, api *sdk.API, plaintextB64, algorithm, } defer cleanupOut() - ciphertext, apiErr := api.CipherEncrypt(plaintext, algorithm) + ctx := context.Background() + + ciphertext, apiErr := api.CipherEncrypt(ctx, plaintext, algorithm) if stdout.HandleAPIError(cmd, apiErr) { return } diff --git a/app/spike/internal/cmd/operator/recover.go b/app/spike/internal/cmd/operator/recover.go index b221578e..4273a188 100644 --- a/app/spike/internal/cmd/operator/recover.go +++ b/app/spike/internal/cmd/operator/recover.go @@ -5,6 +5,7 @@ package operator import ( + "context" "encoding/hex" "fmt" "os" @@ -75,7 +76,9 @@ func newOperatorRecoverCommand( api := spike.NewWithSource(source) - shards, apiErr := api.Recover() + ctx := context.Background() + + shards, apiErr := api.Recover(ctx) // Security: clean the shards when we no longer need them. defer func() { for _, shard := range shards { diff --git a/app/spike/internal/cmd/operator/restore.go b/app/spike/internal/cmd/operator/restore.go index b89faf2f..8a943d0e 100644 --- a/app/spike/internal/cmd/operator/restore.go +++ b/app/spike/internal/cmd/operator/restore.go @@ -5,6 +5,7 @@ package operator import ( + "context" "encoding/hex" "os" "strconv" @@ -144,7 +145,9 @@ func newOperatorRestoreCommand( return } - status, restoreErr := api.Restore(ix, &shardToRestore) + ctx := context.Background() + + status, restoreErr := api.Restore(ctx, ix, &shardToRestore) // Security: reset shardToRestore immediately after recovery. mem.ClearRawBytes(&shardToRestore) if restoreErr != nil { diff --git a/app/spike/internal/cmd/policy/apply.go b/app/spike/internal/cmd/policy/apply.go index f3e1fd68..b67e2022 100644 --- a/app/spike/internal/cmd/policy/apply.go +++ b/app/spike/internal/cmd/policy/apply.go @@ -5,6 +5,8 @@ package policy import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -163,8 +165,10 @@ func newPolicyApplyCommand( return } + ctx := context.Background() + // Apply policy using upsert semantics - policyErr := api.CreatePolicy(policy.Name, policy.SpiffeIDPattern, + policyErr := api.CreatePolicy(ctx, policy.Name, policy.SpiffeIDPattern, policy.PathPattern, permissions) if stdout.HandleAPIError(c, policyErr) { return diff --git a/app/spike/internal/cmd/policy/create.go b/app/spike/internal/cmd/policy/create.go index 8886aa09..e93ce315 100644 --- a/app/spike/internal/cmd/policy/create.go +++ b/app/spike/internal/cmd/policy/create.go @@ -5,6 +5,8 @@ package policy import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -136,8 +138,10 @@ func newPolicyCreateCommand( return } + ctx := context.Background() + // Create policy - apiErr = api.CreatePolicy(name, SPIFFEIDPattern, + apiErr = api.CreatePolicy(ctx, name, SPIFFEIDPattern, pathPattern, permissions) if stdout.HandleAPIError(c, apiErr) { return diff --git a/app/spike/internal/cmd/policy/delete.go b/app/spike/internal/cmd/policy/delete.go index 9b780d91..d59c9ef5 100644 --- a/app/spike/internal/cmd/policy/delete.go +++ b/app/spike/internal/cmd/policy/delete.go @@ -6,6 +6,7 @@ package policy import ( "bufio" + "context" "os" "strings" @@ -105,7 +106,9 @@ func newPolicyDeleteCommand( return } - deleteErr := api.DeletePolicy(policyID) + ctx := context.Background() + + deleteErr := api.DeletePolicy(ctx, policyID) if stdout.HandleAPIError(c, deleteErr) { return } diff --git a/app/spike/internal/cmd/policy/filter.go b/app/spike/internal/cmd/policy/filter.go index ed5801b3..37f3a40b 100644 --- a/app/spike/internal/cmd/policy/filter.go +++ b/app/spike/internal/cmd/policy/filter.go @@ -5,6 +5,7 @@ package policy import ( + "context" "regexp" "strings" @@ -28,7 +29,9 @@ import ( func findPolicyByName( api *spike.API, name string, ) (string, *sdkErrors.SDKError) { - policies, err := api.ListPolicies("", "") + ctx := context.Background() + + policies, err := api.ListAllPolicies(ctx) if err != nil { return "", err } diff --git a/app/spike/internal/cmd/policy/get.go b/app/spike/internal/cmd/policy/get.go index 1b05d93c..d1bcbc0d 100644 --- a/app/spike/internal/cmd/policy/get.go +++ b/app/spike/internal/cmd/policy/get.go @@ -5,6 +5,8 @@ package policy import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -113,7 +115,9 @@ func newPolicyGetCommand( return } - policy, apiErr := api.GetPolicy(policyID) + ctx := context.Background() + + policy, apiErr := api.GetPolicy(ctx, policyID) if stdout.HandleAPIError(c, apiErr) { return } diff --git a/app/spike/internal/cmd/policy/list.go b/app/spike/internal/cmd/policy/list.go index 590ac0a6..bac95d6a 100644 --- a/app/spike/internal/cmd/policy/list.go +++ b/app/spike/internal/cmd/policy/list.go @@ -5,6 +5,8 @@ package policy import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -114,7 +116,9 @@ func newPolicyListCommand( api := spike.NewWithSource(source) - policies, err := api.ListPolicies(SPIFFEIDPattern, pathPattern) + ctx := context.Background() + + policies, err := api.ListPolicies(ctx, SPIFFEIDPattern, pathPattern) if stdout.HandleAPIError(c, err) { return } diff --git a/app/spike/internal/cmd/policy/validation.go b/app/spike/internal/cmd/policy/validation.go index 079ba2fc..fa8d85c7 100644 --- a/app/spike/internal/cmd/policy/validation.go +++ b/app/spike/internal/cmd/policy/validation.go @@ -5,6 +5,8 @@ package policy import ( + "context" + spike "github.com/spiffe/spike-sdk-go/api" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/validation" @@ -27,7 +29,9 @@ var validatePermissions = validation.ValidatePermissions func checkPolicyNameExists( api *spike.API, name string, ) (bool, *sdkErrors.SDKError) { - policies, err := api.ListPolicies("", "") + ctx := context.Background() + + policies, err := api.ListAllPolicies(ctx) if err != nil { return false, err } diff --git a/app/spike/internal/cmd/secret/delete.go b/app/spike/internal/cmd/secret/delete.go index c10bdbd8..e3f2f8ae 100644 --- a/app/spike/internal/cmd/secret/delete.go +++ b/app/spike/internal/cmd/secret/delete.go @@ -5,6 +5,7 @@ package secret import ( + "context" "strconv" "strings" @@ -49,7 +50,7 @@ import ( // - Version numbers are valid non-negative integers // - Version strings are properly formatted func newSecretDeleteCommand( - source *workloadapi.X509Source, SPIFFEID string, + source *workloadapi.X509Source, SPIFFEID string, ) *cobra.Command { var deleteCmd = &cobra.Command{ Use: "delete ", @@ -113,7 +114,9 @@ Examples: vv = []int{} } - err := api.DeleteSecretVersions(path, vv) + ctx := context.Background() + + err := api.DeleteSecretVersions(ctx, path, vv) if stdout.HandleAPIError(cmd, err) { return } diff --git a/app/spike/internal/cmd/secret/get.go b/app/spike/internal/cmd/secret/get.go index 44294279..1422eeef 100644 --- a/app/spike/internal/cmd/secret/get.go +++ b/app/spike/internal/cmd/secret/get.go @@ -5,6 +5,7 @@ package secret import ( + "context" "encoding/json" "slices" @@ -81,7 +82,9 @@ func newSecretGetCommand( return } - secret, err := api.GetSecretVersion(path, version) + ctx := context.Background() + + secret, err := api.GetSecretVersion(ctx, path, version) if stdout.HandleAPIError(cmd, err) { return } diff --git a/app/spike/internal/cmd/secret/list.go b/app/spike/internal/cmd/secret/list.go index be86458e..e18e631d 100644 --- a/app/spike/internal/cmd/secret/list.go +++ b/app/spike/internal/cmd/secret/list.go @@ -5,6 +5,8 @@ package secret import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -55,7 +57,9 @@ func newSecretListCommand( api := spike.NewWithSource(source) - keys, err := api.ListSecretKeys() + ctx := context.Background() + + keys, err := api.ListSecretKeys(ctx) if stdout.HandleAPIError(cmd, err) { return } diff --git a/app/spike/internal/cmd/secret/metadata_get.go b/app/spike/internal/cmd/secret/metadata_get.go index f802ecfb..beeb64e9 100644 --- a/app/spike/internal/cmd/secret/metadata_get.go +++ b/app/spike/internal/cmd/secret/metadata_get.go @@ -5,6 +5,8 @@ package secret import ( + "context" + "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" @@ -67,7 +69,9 @@ func newSecretMetadataGetCommand( path := args[0] version, _ := cmd.Flags().GetInt("version") - secret, err := api.GetSecretMetadata(path, version) + ctx := context.Background() + + secret, err := api.GetSecretMetadata(ctx, path, version) if stdout.HandleAPIError(cmd, err) { return } diff --git a/app/spike/internal/cmd/secret/put.go b/app/spike/internal/cmd/secret/put.go index c043066a..759b3f6d 100644 --- a/app/spike/internal/cmd/secret/put.go +++ b/app/spike/internal/cmd/secret/put.go @@ -5,6 +5,7 @@ package secret import ( + "context" "strings" "github.com/spf13/cobra" @@ -91,7 +92,9 @@ func newSecretPutCommand( return } - err := api.PutSecret(path, values) + ctx := context.Background() + + err := api.PutSecret(ctx, path, values) if stdout.HandleAPIError(cmd, err) { return } diff --git a/app/spike/internal/cmd/secret/undelete.go b/app/spike/internal/cmd/secret/undelete.go index 9e269921..b79dd64c 100644 --- a/app/spike/internal/cmd/secret/undelete.go +++ b/app/spike/internal/cmd/secret/undelete.go @@ -5,6 +5,7 @@ package secret import ( + "context" "strconv" "strings" @@ -104,7 +105,9 @@ Examples: vv = append(vv, version) } - err := api.UndeleteSecret(path, vv) + ctx := context.Background() + + err := api.UndeleteSecret(ctx, path, vv) if stdout.HandleAPIError(cmd, err) { return } diff --git a/go.mod b/go.mod index bbb6a380..22f4eb23 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.18.5 + github.com/spiffe/spike-sdk-go v0.19.1 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 04ffc28a..95abe9ac 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.18.5 h1:N0hw5Wlo+8M+s7BZTIpsdcmobdKJ+lFbDzKPX9wuEFk= -github.com/spiffe/spike-sdk-go v0.18.5/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.19.1 h1:aY/xCqIZgMJ0B/hYVhqepm3wGXM8xZKvBzb+ieW4kCw= +github.com/spiffe/spike-sdk-go v0.19.1/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From 0a1c8f69e3260fdec1954994747b497b36caaca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Mon, 19 Jan 2026 03:17:58 +0000 Subject: [PATCH 25/28] Fix potential resource leak and formatting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move defer cleanupIn() before error check to satisfy linter. The openInput function returns a no-op cleanup on error, so calling it unconditionally is safe and more defensive. Also fix indentation in encrypt_impl.go and delete.go. Signed-off-by: Volkan Özçelik --- app/spike/internal/cmd/cipher/encrypt_impl.go | 4 ++-- app/spike/internal/cmd/secret/delete.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/spike/internal/cmd/cipher/encrypt_impl.go b/app/spike/internal/cmd/cipher/encrypt_impl.go index 78b7e608..804ca0fb 100644 --- a/app/spike/internal/cmd/cipher/encrypt_impl.go +++ b/app/spike/internal/cmd/cipher/encrypt_impl.go @@ -40,11 +40,11 @@ func encryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) { } in, cleanupIn, inputErr := openInput(inFile) + defer cleanupIn() // safe: openInput returns noop on error. if inputErr != nil { cmd.PrintErrf("Error: %v\n", inputErr) return } - defer cleanupIn() out, cleanupOut, outputErr := openOutput(outFile) if outputErr != nil { @@ -79,7 +79,7 @@ func encryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) { // The function prints errors directly to stderr and returns without error // propagation, following the CLI command pattern. func encryptJSON(cmd *cobra.Command, api *sdk.API, plaintextB64, algorithm, - outFile string) { + outFile string) { plaintext, err := base64.StdEncoding.DecodeString(plaintextB64) if err != nil { cmd.PrintErrln("Error: Invalid --plaintext base64.") diff --git a/app/spike/internal/cmd/secret/delete.go b/app/spike/internal/cmd/secret/delete.go index e3f2f8ae..d352c3f3 100644 --- a/app/spike/internal/cmd/secret/delete.go +++ b/app/spike/internal/cmd/secret/delete.go @@ -50,7 +50,7 @@ import ( // - Version numbers are valid non-negative integers // - Version strings are properly formatted func newSecretDeleteCommand( - source *workloadapi.X509Source, SPIFFEID string, + source *workloadapi.X509Source, SPIFFEID string, ) *cobra.Command { var deleteCmd = &cobra.Command{ Use: "delete ", From 867c538b075db515cfec8968f24f0b2ecadfb724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Mon, 19 Jan 2026 03:21:22 +0000 Subject: [PATCH 26/28] Fix indentation in encryptJSON function signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/spike/internal/cmd/cipher/encrypt_impl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/spike/internal/cmd/cipher/encrypt_impl.go b/app/spike/internal/cmd/cipher/encrypt_impl.go index 804ca0fb..c2c947dc 100644 --- a/app/spike/internal/cmd/cipher/encrypt_impl.go +++ b/app/spike/internal/cmd/cipher/encrypt_impl.go @@ -79,7 +79,7 @@ func encryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) { // The function prints errors directly to stderr and returns without error // propagation, following the CLI command pattern. func encryptJSON(cmd *cobra.Command, api *sdk.API, plaintextB64, algorithm, - outFile string) { + outFile string) { plaintext, err := base64.StdEncoding.DecodeString(plaintextB64) if err != nil { cmd.PrintErrln("Error: Invalid --plaintext base64.") From f5aa7c045f04e563f7a6f65db7003de6f9527025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Mon, 19 Jan 2026 18:02:11 +0000 Subject: [PATCH 27/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- app/keeper/internal/route/store/contribute.go | 1 - app/keeper/internal/route/store/shard.go | 1 - app/nexus/README.md | 6 +- app/nexus/internal/route/acl/policy/put.go | 1 - app/nexus/internal/route/bootstrap/verify.go | 1 - app/nexus/internal/route/cipher/config.go | 9 -- app/nexus/internal/route/cipher/encrypt.go | 1 - app/nexus/internal/route/operator/recover.go | 1 - app/nexus/internal/route/operator/restore.go | 1 - app/nexus/internal/route/secret/get.go | 1 - app/nexus/internal/route/secret/list.go | 1 - app/nexus/internal/route/secret/map_test.go | 9 +- .../internal/route/secret/metadata_get.go | 1 - app/nexus/internal/route/secret/put.go | 1 - app/nexus/internal/route/secret/undelete.go | 1 - .../route/secret/undelete_intercept.go | 1 + .../state/backend/sqlite/persist/io.go | 29 ++++ .../state/backend/sqlite/persist/policy.go | 117 ++++------------ .../state/backend/sqlite/persist/schema.go | 19 --- .../state/backend/sqlite/persist/secret.go | 81 ++++++----- .../state/backend/sqlite/persist/tx.go | 68 +++++++++ app/nexus/internal/state/base/policy.go | 4 +- .../internal/state/base/policy_sqlite_test.go | 24 +++- app/nexus/internal/state/base/validation.go | 52 ------- .../internal/state/base/validation_test.go | 65 +-------- app/nexus/internal/state/persist/init.go | 50 +------ .../internal/state/persist/init_memory.go | 5 +- .../internal/state/persist/init_sqlite.go | 16 +-- app/spike/internal/cmd/cipher/decrypt.go | 5 +- app/spike/internal/cmd/cipher/encrypt.go | 5 +- app/spike/internal/cmd/operator/recover.go | 10 +- app/spike/internal/cmd/operator/restore.go | 15 +- app/spike/internal/cmd/policy/apply.go | 9 +- app/spike/internal/cmd/policy/create.go | 9 +- app/spike/internal/cmd/policy/delete.go | 9 +- app/spike/internal/cmd/policy/format.go | 18 ++- app/spike/internal/cmd/policy/get.go | 9 +- app/spike/internal/cmd/policy/list.go | 9 +- app/spike/internal/cmd/policy/test_helper.go | 5 +- app/spike/internal/cmd/secret/delete.go | 9 +- app/spike/internal/cmd/secret/get.go | 9 +- app/spike/internal/cmd/secret/list.go | 9 +- app/spike/internal/cmd/secret/metadata_get.go | 10 +- app/spike/internal/cmd/secret/put.go | 9 +- app/spike/internal/cmd/secret/undelete.go | 9 +- app/spike/internal/trust/spiffeid.go | 54 -------- app/spike/internal/trust/spiffeid_test.go | 129 ------------------ future/004-spike-multi-tenancy.md | 1 + go.mod | 2 +- go.sum | 4 +- 50 files changed, 259 insertions(+), 656 deletions(-) delete mode 100644 app/nexus/internal/route/cipher/config.go create mode 100644 app/nexus/internal/state/backend/sqlite/persist/io.go create mode 100644 app/nexus/internal/state/backend/sqlite/persist/tx.go delete mode 100644 app/spike/internal/trust/spiffeid.go delete mode 100644 app/spike/internal/trust/spiffeid_test.go create mode 100644 future/004-spike-multi-tenancy.md diff --git a/app/keeper/internal/route/store/contribute.go b/app/keeper/internal/route/store/contribute.go index 7be66529..d3b42e9e 100644 --- a/app/keeper/internal/route/store/contribute.go +++ b/app/keeper/internal/route/store/contribute.go @@ -61,7 +61,6 @@ func RouteContribute( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RouteContribute" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) request, err := net.ReadParseAndGuard[ diff --git a/app/keeper/internal/route/store/shard.go b/app/keeper/internal/route/store/shard.go index 7fef8ede..e8176bc4 100644 --- a/app/keeper/internal/route/store/shard.go +++ b/app/keeper/internal/route/store/shard.go @@ -53,7 +53,6 @@ func RouteShard( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RouteShard" - journal.AuditRequest(fName, r, audit, journal.AuditRead) _, err := net.ReadParseAndGuard[ diff --git a/app/nexus/README.md b/app/nexus/README.md index 50e6dbcc..ba8234fe 100644 --- a/app/nexus/README.md +++ b/app/nexus/README.md @@ -10,10 +10,10 @@ storage with versioning, soft-delete support, and SPIFFE-based access control. SPIKE Nexus supports multiple storage backends, configured via the `SPIKE_NEXUS_BACKEND_STORE` environment variable: -- **sqlite** (production): Persistent encrypted storage using SQLite. Secrets +* **sqlite** (production): Persistent encrypted storage using SQLite. Secrets are stored in `~/.spike/data/spike.db` with AES-256-GCM encryption at rest. -- **memory** (development/testing): In-memory storage. Data is lost on restart. -- **lite**: Encryption-only mode for encryption-as-a-service use cases. No +* **memory** (development/testing): In-memory storage. Data is lost on restart. +* **lite**: Encryption-only mode for encryption-as-a-service use cases. No secret persistence; provides cipher access for external encryption needs. ### Root Key Management diff --git a/app/nexus/internal/route/acl/policy/put.go b/app/nexus/internal/route/acl/policy/put.go index f43fbb40..a4623b34 100644 --- a/app/nexus/internal/route/acl/policy/put.go +++ b/app/nexus/internal/route/acl/policy/put.go @@ -65,7 +65,6 @@ func RoutePutPolicy( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RoutePutPolicy" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) request, guardErr := net.ReadParseAndGuard( diff --git a/app/nexus/internal/route/bootstrap/verify.go b/app/nexus/internal/route/bootstrap/verify.go index c16b3b7a..bfaf2df2 100644 --- a/app/nexus/internal/route/bootstrap/verify.go +++ b/app/nexus/internal/route/bootstrap/verify.go @@ -56,7 +56,6 @@ func RouteVerify( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RouteVerify" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) request, parseErr := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/cipher/config.go b/app/nexus/internal/route/cipher/config.go deleted file mode 100644 index 165d336a..00000000 --- a/app/nexus/internal/route/cipher/config.go +++ /dev/null @@ -1,9 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package cipher - -const spikeCipherVersion = byte('1') -const headerKeyContentType = "Content-Type" -const headerValueOctetStream = "application/octet-stream" diff --git a/app/nexus/internal/route/cipher/encrypt.go b/app/nexus/internal/route/cipher/encrypt.go index ecb42c30..34871645 100644 --- a/app/nexus/internal/route/cipher/encrypt.go +++ b/app/nexus/internal/route/cipher/encrypt.go @@ -56,7 +56,6 @@ func RouteEncrypt( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RouteEncrypt" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) return net.DispatchByContentType( diff --git a/app/nexus/internal/route/operator/recover.go b/app/nexus/internal/route/operator/recover.go index 05daea9e..8b22feef 100644 --- a/app/nexus/internal/route/operator/recover.go +++ b/app/nexus/internal/route/operator/recover.go @@ -46,7 +46,6 @@ func RouteRecover( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "routeRecover" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) _, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/operator/restore.go b/app/nexus/internal/route/operator/restore.go index a1f04fb0..f1ece31f 100644 --- a/app/nexus/internal/route/operator/restore.go +++ b/app/nexus/internal/route/operator/restore.go @@ -61,7 +61,6 @@ func RouteRestore( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "routeRestore" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) if env.BackendStoreTypeVal() == env.Memory { diff --git a/app/nexus/internal/route/secret/get.go b/app/nexus/internal/route/secret/get.go index 7ed69e60..f570dbf5 100644 --- a/app/nexus/internal/route/secret/get.go +++ b/app/nexus/internal/route/secret/get.go @@ -66,7 +66,6 @@ func RouteGetSecret( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "routeGetSecret" - journal.AuditRequest(fName, r, audit, journal.AuditRead) request, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/secret/list.go b/app/nexus/internal/route/secret/list.go index 055eaf9e..ad863b28 100644 --- a/app/nexus/internal/route/secret/list.go +++ b/app/nexus/internal/route/secret/list.go @@ -57,7 +57,6 @@ func RouteListPaths( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RouteListPaths" - journal.AuditRequest(fName, r, audit, journal.AuditList) _, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/secret/map_test.go b/app/nexus/internal/route/secret/map_test.go index 91c5758d..641c3118 100644 --- a/app/nexus/internal/route/secret/map_test.go +++ b/app/nexus/internal/route/secret/map_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" "github.com/spiffe/spike-sdk-go/kv" ) @@ -23,7 +24,7 @@ func TestToSecretMetadataSuccessResponse_EmptyVersions(t *testing.T) { }, } - response := toSecretMetadataSuccessResponse(secret) + response := reqres.ValueToSecretMetadataSuccessResponse(secret) if len(response.SecretMetadata.Versions) != 0 { t.Errorf("toSecretMetadataSuccessResponse() versions = %d, want 0", @@ -57,7 +58,7 @@ func TestToSecretMetadataSuccessResponse_SingleVersion(t *testing.T) { }, } - response := toSecretMetadataSuccessResponse(secret) + response := reqres.ValueToSecretMetadataSuccessResponse(secret) if len(response.SecretMetadata.Versions) != 1 { t.Errorf("toSecretMetadataSuccessResponse() versions = %d, want 1", @@ -125,7 +126,7 @@ func TestToSecretMetadataSuccessResponse_MultipleVersions(t *testing.T) { }, } - response := toSecretMetadataSuccessResponse(secret) + response := reqres.ValueToSecretMetadataSuccessResponse(secret) if len(response.SecretMetadata.Versions) != 3 { t.Errorf("toSecretMetadataSuccessResponse() versions = %d, want 3", @@ -174,7 +175,7 @@ func TestToSecretMetadataSuccessResponse_ResponseIsSuccess(t *testing.T) { }, } - response := toSecretMetadataSuccessResponse(secret) + response := reqres.ValueToSecretMetadataSuccessResponse(secret) // The response should have an empty error message (success) if response.Err != "" { diff --git a/app/nexus/internal/route/secret/metadata_get.go b/app/nexus/internal/route/secret/metadata_get.go index 96e3bbc8..ebcde67f 100644 --- a/app/nexus/internal/route/secret/metadata_get.go +++ b/app/nexus/internal/route/secret/metadata_get.go @@ -80,7 +80,6 @@ func RouteGetSecretMetadata( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "routeGetSecretMetadata" - journal.AuditRequest(fName, r, audit, journal.AuditRead) request, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/secret/put.go b/app/nexus/internal/route/secret/put.go index c23ad33a..799d8fb2 100644 --- a/app/nexus/internal/route/secret/put.go +++ b/app/nexus/internal/route/secret/put.go @@ -52,7 +52,6 @@ func RoutePutSecret( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RoutePutSecret" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) request, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/secret/undelete.go b/app/nexus/internal/route/secret/undelete.go index a1b23dfb..73ad34ad 100644 --- a/app/nexus/internal/route/secret/undelete.go +++ b/app/nexus/internal/route/secret/undelete.go @@ -54,7 +54,6 @@ func RouteUndeleteSecret( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "routeUndeleteSecret" - journal.AuditRequest(fName, r, audit, journal.AuditUndelete) request, err := net.ReadParseAndGuard[ diff --git a/app/nexus/internal/route/secret/undelete_intercept.go b/app/nexus/internal/route/secret/undelete_intercept.go index 571c7866..c26d2010 100644 --- a/app/nexus/internal/route/secret/undelete_intercept.go +++ b/app/nexus/internal/route/secret/undelete_intercept.go @@ -11,6 +11,7 @@ import ( sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/net" "github.com/spiffe/spike-sdk-go/predicate" + state "github.com/spiffe/spike/app/nexus/internal/state/base" ) diff --git a/app/nexus/internal/state/backend/sqlite/persist/io.go b/app/nexus/internal/state/backend/sqlite/persist/io.go new file mode 100644 index 00000000..2761a1ca --- /dev/null +++ b/app/nexus/internal/state/backend/sqlite/persist/io.go @@ -0,0 +1,29 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +package persist + +import ( + "os" + + sdkErrors "github.com/spiffe/spike-sdk-go/errors" +) + +// createDataDir creates the data directory for the SQLite database if it +// does not already exist. The directory path is determined by the +// s.Opts.DataDir field. The directory is created with 0750 permissions, +// allowing `read`, `write`, and `execute` for the owner, and `read` and +// `execute` for the group. +// +// Returns: +// - *sdkErrors.SDKError: An error if the directory creation fails, wrapped +// in ErrFSDirectoryCreationFailed. Returns nil on success. +func (s *DataStore) createDataDir() *sdkErrors.SDKError { + err := os.MkdirAll(s.Opts.DataDir, 0750) + if err != nil { + return sdkErrors.ErrFSDirectoryCreationFailed.Wrap(err) + } + + return nil +} diff --git a/app/nexus/internal/state/backend/sqlite/persist/policy.go b/app/nexus/internal/state/backend/sqlite/persist/policy.go index 87483e36..302f753c 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/policy.go +++ b/app/nexus/internal/state/backend/sqlite/persist/policy.go @@ -35,45 +35,14 @@ import ( func (s *DataStore) DeletePolicy( ctx context.Context, id string, ) *sdkErrors.SDKError { - const fName = "DeletePolicy" - - validation.NonNilContextOrDie(ctx, fName) - - s.mu.Lock() - defer s.mu.Unlock() - - tx, beginErr := s.db.BeginTx( - ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}, - ) - if beginErr != nil { - failErr := sdkErrors.ErrTransactionBeginFailed.Wrap(beginErr) - return failErr - } - - committed := false - defer func(tx *sql.Tx) { - if !committed { - rollbackErr := tx.Rollback() - if rollbackErr != nil { - failErr := sdkErrors.ErrTransactionRollbackFailed.Wrap(rollbackErr) - log.WarnErr(fName, *failErr) + return s.withSerializableTx(ctx, "DeletePolicy", + func(tx *sql.Tx) *sdkErrors.SDKError { + _, execErr := tx.ExecContext(ctx, ddl.QueryDeletePolicy, id) + if execErr != nil { + return sdkErrors.ErrEntityQueryFailed.Wrap(execErr) } - } - }(tx) - - _, execErr := tx.ExecContext(ctx, ddl.QueryDeletePolicy, id) - if execErr != nil { - failErr := sdkErrors.ErrEntityQueryFailed.Wrap(execErr) - return failErr - } - - if commitErr := tx.Commit(); commitErr != nil { - failErr := sdkErrors.ErrTransactionCommitFailed.Wrap(commitErr) - return failErr - } - - committed = true - return nil + return nil + }) } // StorePolicy saves or updates a policy in the database. @@ -92,32 +61,6 @@ func (s *DataStore) DeletePolicy( func (s *DataStore) StorePolicy( ctx context.Context, policy data.Policy, ) *sdkErrors.SDKError { - const fName = "StorePolicy" - - validation.NonNilContextOrDie(ctx, fName) - - s.mu.Lock() - defer s.mu.Unlock() - - tx, beginErr := s.db.BeginTx( - ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}, - ) - if beginErr != nil { - failErr := sdkErrors.ErrTransactionBeginFailed.Wrap(beginErr) - return failErr - } - - committed := false - defer func(tx *sql.Tx) { - if !committed { - rollbackErr := tx.Rollback() - if rollbackErr != nil { - failErr := sdkErrors.ErrTransactionRollbackFailed.Wrap(rollbackErr) - log.WarnErr(fName, *failErr) - } - } - }(tx) - // Serialize permissions to comma-separated string permissionsStr := "" if len(policy.Permissions) > 0 { @@ -128,7 +71,7 @@ func (s *DataStore) StorePolicy( permissionsStr = strings.Join(permissions, ",") } - // Encryption + // Encryption (done before transaction since it doesn't need the database) nonce, nonceErr := generateNonce(s) if nonceErr != nil { return sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(nonceErr) @@ -147,7 +90,6 @@ func (s *DataStore) StorePolicy( encryptedPathPattern, pathErr := encryptWithNonce( s, nonce, []byte(policy.PathPattern), ) - if pathErr != nil { failErr := sdkErrors.ErrCryptoEncryptionFailed.Wrap(pathErr) failErr.Msg = fmt.Sprintf( @@ -155,6 +97,7 @@ func (s *DataStore) StorePolicy( ) return failErr } + encryptedPermissions, permErr := encryptWithNonce( s, nonce, []byte(permissionsStr), ) @@ -166,29 +109,25 @@ func (s *DataStore) StorePolicy( return failErr } - _, execErr := tx.ExecContext(ctx, ddl.QueryUpsertPolicy, - policy.ID, - policy.Name, - nonce, - encryptedSpiffeID, - encryptedPathPattern, - encryptedPermissions, - policy.CreatedAt.Unix(), - policy.UpdatedAt.Unix(), - ) - - if execErr != nil { - failErr := sdkErrors.ErrEntityQueryFailed.Wrap(execErr) - failErr.Msg = fmt.Sprintf("failed to upsert policy %s", policy.ID) - return failErr - } - - if commitErr := tx.Commit(); commitErr != nil { - return sdkErrors.ErrTransactionCommitFailed.Wrap(commitErr) - } - - committed = true - return nil + return s.withSerializableTx(ctx, "StorePolicy", + func(tx *sql.Tx) *sdkErrors.SDKError { + _, execErr := tx.ExecContext(ctx, ddl.QueryUpsertPolicy, + policy.ID, + policy.Name, + nonce, + encryptedSpiffeID, + encryptedPathPattern, + encryptedPermissions, + policy.CreatedAt.Unix(), + policy.UpdatedAt.Unix(), + ) + if execErr != nil { + failErr := sdkErrors.ErrEntityQueryFailed.Wrap(execErr) + failErr.Msg = fmt.Sprintf("failed to upsert policy %s", policy.ID) + return failErr + } + return nil + }) } // LoadPolicy retrieves a policy from the database and compiles its patterns. diff --git a/app/nexus/internal/state/backend/sqlite/persist/schema.go b/app/nexus/internal/state/backend/sqlite/persist/schema.go index 3413fcde..8a45f806 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/schema.go +++ b/app/nexus/internal/state/backend/sqlite/persist/schema.go @@ -7,7 +7,6 @@ package persist import ( "context" "database/sql" - "os" sdkErrors "github.com/spiffe/spike-sdk-go/errors" @@ -15,24 +14,6 @@ import ( "github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl" ) -// createDataDir creates the data directory for the SQLite database if it -// does not already exist. The directory path is determined by the -// s.Opts.DataDir field. The directory is created with 0750 permissions, -// allowing `read`, `write`, and `execute` for the owner, and `read` and -// `execute` for the group. -// -// Returns: -// - *sdkErrors.SDKError: An error if the directory creation fails, wrapped -// in ErrFSDirectoryCreationFailed. Returns nil on success. -func (s *DataStore) createDataDir() *sdkErrors.SDKError { - err := os.MkdirAll(s.Opts.DataDir, 0750) - if err != nil { - return sdkErrors.ErrFSDirectoryCreationFailed.Wrap(err) - } - - return nil -} - // createTables initializes the database schema by executing the DDL // statements to create all required tables for secret and policy storage. // This function is idempotent and can be called multiple times safely. diff --git a/app/nexus/internal/state/backend/sqlite/persist/secret.go b/app/nexus/internal/state/backend/sqlite/persist/secret.go index b52931f5..71859fcc 100644 --- a/app/nexus/internal/state/backend/sqlite/persist/secret.go +++ b/app/nexus/internal/state/backend/sqlite/persist/secret.go @@ -42,41 +42,16 @@ import ( func (s *DataStore) StoreSecret( ctx context.Context, path string, secret kv.Value, ) *sdkErrors.SDKError { - const fName = "StoreSecret" - - validation.NonNilContextOrDie(ctx, fName) - - s.mu.Lock() - defer s.mu.Unlock() - - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - if err != nil { - return sdkErrors.ErrTransactionBeginFailed.Wrap(err) - } - - committed := false - - defer func(tx *sql.Tx) { - if !committed { - err := tx.Rollback() - if err != nil { - failErr := *sdkErrors.ErrTransactionRollbackFailed.Clone() - log.WarnErr(fName, failErr) - } - } - }(tx) - - // Update metadata - _, err = tx.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, - path, secret.Metadata.CurrentVersion, secret.Metadata.OldestVersion, - secret.Metadata.CreatedTime, - secret.Metadata.UpdatedTime, secret.Metadata.MaxVersions, - ) - if err != nil { - return sdkErrors.ErrEntityQueryFailed.Wrap(err) + // Pre-encrypt all versions before starting the transaction + type encryptedVersion struct { + version int + nonce []byte + encrypted []byte + createdTime any + deletedTime any } - // Update versions + encryptedVersions := make([]encryptedVersion, 0, len(secret.Versions)) for version, sv := range secret.Versions { md, marshalErr := json.Marshal(sv.Data) if marshalErr != nil { @@ -88,19 +63,39 @@ func (s *DataStore) StoreSecret( return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr) } - _, execErr := tx.ExecContext(ctx, ddl.QueryUpsertSecret, - path, version, nonce, encrypted, sv.CreatedTime, sv.DeletedTime) - if execErr != nil { - return sdkErrors.ErrEntityQueryFailed.Wrap(execErr) - } + encryptedVersions = append(encryptedVersions, encryptedVersion{ + version: version, + nonce: nonce, + encrypted: encrypted, + createdTime: sv.CreatedTime, + deletedTime: sv.DeletedTime, + }) } - if err := tx.Commit(); err != nil { - return sdkErrors.ErrTransactionCommitFailed.Wrap(err) - } + return s.withSerializableTx(ctx, "StoreSecret", + func(tx *sql.Tx) *sdkErrors.SDKError { + // Update metadata + _, metaErr := tx.ExecContext(ctx, ddl.QueryUpdateSecretMetadata, + path, secret.Metadata.CurrentVersion, secret.Metadata.OldestVersion, + secret.Metadata.CreatedTime, + secret.Metadata.UpdatedTime, secret.Metadata.MaxVersions, + ) + if metaErr != nil { + return sdkErrors.ErrEntityQueryFailed.Wrap(metaErr) + } + + // Update versions + for _, ev := range encryptedVersions { + _, execErr := tx.ExecContext(ctx, ddl.QueryUpsertSecret, + path, ev.version, ev.nonce, ev.encrypted, + ev.createdTime, ev.deletedTime) + if execErr != nil { + return sdkErrors.ErrEntityQueryFailed.Wrap(execErr) + } + } - committed = true - return nil + return nil + }) } // LoadSecret retrieves a secret and all its versions from the specified path. diff --git a/app/nexus/internal/state/backend/sqlite/persist/tx.go b/app/nexus/internal/state/backend/sqlite/persist/tx.go new file mode 100644 index 00000000..60e19fcb --- /dev/null +++ b/app/nexus/internal/state/backend/sqlite/persist/tx.go @@ -0,0 +1,68 @@ +// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +// \\\\\ Copyright 2024-present SPIKE contributors. +// \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +package persist + +import ( + "context" + "database/sql" + + sdkErrors "github.com/spiffe/spike-sdk-go/errors" + "github.com/spiffe/spike-sdk-go/log" + "github.com/spiffe/spike-sdk-go/validation" +) + +// withSerializableTx executes fn within a serializable transaction. +// It handles begin, commit, and automatic rollback on error. +// +// The function acquires a write lock on the DataStore for the duration of the +// transaction to ensure thread safety. The transaction uses serializable +// isolation level for strict consistency. +// +// Parameters: +// - ctx: Context for the database operation +// - fName: Function name for logging purposes +// - fn: The work to execute within the transaction +// +// Returns: +// - *sdkErrors.SDKError: nil on success, or an error if transaction +// operations fail or fn returns an error +func (s *DataStore) withSerializableTx( + ctx context.Context, + fName string, + fn func(tx *sql.Tx) *sdkErrors.SDKError, +) *sdkErrors.SDKError { + validation.NonNilContextOrDie(ctx, fName) + + s.mu.Lock() + defer s.mu.Unlock() + + tx, beginErr := s.db.BeginTx( + ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}, + ) + if beginErr != nil { + return sdkErrors.ErrTransactionBeginFailed.Wrap(beginErr) + } + + committed := false + defer func() { + if !committed { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + failErr := sdkErrors.ErrTransactionRollbackFailed.Wrap(rollbackErr) + log.WarnErr(fName, *failErr) + } + } + }() + + if execErr := fn(tx); execErr != nil { + return execErr + } + + if commitErr := tx.Commit(); commitErr != nil { + return sdkErrors.ErrTransactionCommitFailed.Wrap(commitErr) + } + + committed = true + return nil +} diff --git a/app/nexus/internal/state/base/policy.go b/app/nexus/internal/state/base/policy.go index 7be88484..49ea7473 100644 --- a/app/nexus/internal/state/base/policy.go +++ b/app/nexus/internal/state/base/policy.go @@ -13,6 +13,8 @@ import ( "github.com/spiffe/spike-sdk-go/api/entity/data" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/log" + "github.com/spiffe/spike-sdk-go/validation" + "github.com/spiffe/spike/app/nexus/internal/state/persist" ) @@ -65,7 +67,7 @@ func CheckPolicyAccess( continue } - if verifyPermissions(policy.Permissions, wants) { + if validation.ValidatePolicyPermissions(policy.Permissions, wants) { return true } } diff --git a/app/nexus/internal/state/base/policy_sqlite_test.go b/app/nexus/internal/state/base/policy_sqlite_test.go index cb770d45..b573d949 100644 --- a/app/nexus/internal/state/base/policy_sqlite_test.go +++ b/app/nexus/internal/state/base/policy_sqlite_test.go @@ -39,7 +39,9 @@ func TestSQLitePolicy_CreateAndGet(t *testing.T) { Name: "test-policy", SPIFFEIDPattern: "^spiffe://example\\.org/workload$", PathPattern: "^test/secrets/.*$", - Permissions: []data.PolicyPermission{data.PermissionRead, data.PermissionList}, + Permissions: []data.PolicyPermission{ + data.PermissionRead, data.PermissionList, + }, } createdPolicy, createErr := UpsertPolicy(policy) @@ -135,16 +137,28 @@ func TestSQLitePolicy_Persistence(t *testing.T) { } if retrievedPolicy.Name != policy.Name { - t.Errorf("Policy name not persisted correctly: expected %s, got %s", policy.Name, retrievedPolicy.Name) + t.Errorf( + "Policy name not persisted correctly: expected %s, got %s", + policy.Name, retrievedPolicy.Name, + ) } if retrievedPolicy.SPIFFEIDPattern != policy.SPIFFEIDPattern { - t.Errorf("Policy SPIFFE ID pattern not persisted correctly: expected %s, got %s", policy.SPIFFEIDPattern, retrievedPolicy.SPIFFEIDPattern) + t.Errorf( + "Policy SPIFFE ID pattern not persisted correctly: expected %s, got %s", + policy.SPIFFEIDPattern, retrievedPolicy.SPIFFEIDPattern, + ) } if retrievedPolicy.PathPattern != policy.PathPattern { - t.Errorf("Policy pathPattern pattern not persisted correctly: expected %s, got %s", policy.PathPattern, retrievedPolicy.PathPattern) + t.Errorf( + "Policy pathPattern pattern not persisted correctly: expected %s, got %s", + policy.PathPattern, retrievedPolicy.PathPattern, + ) } if !reflect.DeepEqual(retrievedPolicy.Permissions, policy.Permissions) { - t.Errorf("Policy permissions not persisted correctly: expected %v, got %v", policy.Permissions, retrievedPolicy.Permissions) + t.Errorf( + "Policy permissions not persisted correctly: expected %v, got %v", + policy.Permissions, retrievedPolicy.Permissions, + ) } }) } diff --git a/app/nexus/internal/state/base/validation.go b/app/nexus/internal/state/base/validation.go index 12a6b8e7..5ff38c08 100644 --- a/app/nexus/internal/state/base/validation.go +++ b/app/nexus/internal/state/base/validation.go @@ -7,7 +7,6 @@ package base import ( "context" - "github.com/spiffe/spike-sdk-go/api/entity/data" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/kv" @@ -35,54 +34,3 @@ func loadAndValidateSecret(path string) (*kv.Value, *sdkErrors.SDKError) { } return secret, nil } - -// contains checks whether a specific permission exists in the given slice of -// permissions. -// -// Parameters: -// - permissions: The slice of permissions to search -// - permission: The permission to search for -// -// Returns: -// - true if the permission is found in the slice -// - false otherwise -func contains(permissions []data.PolicyPermission, - permission data.PolicyPermission) bool { - for _, p := range permissions { - if p == permission { - return true - } - } - return false -} - -// verifyPermissions checks whether the "haves" permissions satisfy all the -// required "wants" permissions. -// -// The "Super" permission acts as a wildcard that grants all permissions. -// If "Super" is present in haves, this function returns true regardless of -// the wants. -// -// Parameters: -// - haves: The permissions that are available -// - wants: The permissions that are required -// -// Returns: -// - true if all required permissions are satisfied (or "super" is present) -// - false if any required permission is missing -func verifyPermissions( - haves []data.PolicyPermission, - wants []data.PolicyPermission, -) bool { - // The "Super" permission grants all permissions. - if contains(haves, data.PermissionSuper) { - return true - } - - for _, want := range wants { - if !contains(haves, want) { - return false - } - } - return true -} diff --git a/app/nexus/internal/state/base/validation_test.go b/app/nexus/internal/state/base/validation_test.go index eb6f5cb8..9df85ba7 100644 --- a/app/nexus/internal/state/base/validation_test.go +++ b/app/nexus/internal/state/base/validation_test.go @@ -8,45 +8,9 @@ import ( "testing" "github.com/spiffe/spike-sdk-go/api/entity/data" + "github.com/spiffe/spike-sdk-go/validation" ) -func TestContains(t *testing.T) { - permissions := []data.PolicyPermission{ - data.PermissionRead, - data.PermissionWrite, - data.PermissionList, - } - - testCases := []struct { - name string - permission data.PolicyPermission - expected bool - }{ - {"contains read", data.PermissionRead, true}, - {"contains write", data.PermissionWrite, true}, - {"contains list", data.PermissionList, true}, - {"does not contain super", data.PermissionSuper, false}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := contains(permissions, tc.permission) - if result != tc.expected { - t.Errorf("Expected %v, got %v for permission %v", tc.expected, result, tc.permission) - } - }) - } -} - -func TestContains_EmptySlice(t *testing.T) { - var permissions []data.PolicyPermission - - result := contains(permissions, data.PermissionRead) - if result { - t.Error("Expected false for empty permission slice") - } -} - func TestVerifyPermissions_SuperPermissionJoker(t *testing.T) { // Test that super permission acts as a joker and grants all permissions testCases := []struct { @@ -95,7 +59,7 @@ func TestVerifyPermissions_SuperPermissionJoker(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := verifyPermissions(tc.haves, tc.wants) + result := validation.ValidatePolicyPermissions(tc.haves, tc.wants) if result != tc.expected { t.Errorf("Expected %v, got %v for case: %s", tc.expected, result, tc.name) } @@ -157,7 +121,7 @@ func TestVerifyPermissions_SpecificPermissions(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := verifyPermissions(tc.haves, tc.wants) + result := validation.ValidatePolicyPermissions(tc.haves, tc.wants) if result != tc.expected { t.Errorf("Expected %v, got %v for case: %s", tc.expected, result, tc.name) } @@ -195,7 +159,7 @@ func TestVerifyPermissions_SuperWithOtherPermissions(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := verifyPermissions(tc.haves, tc.wants) + result := validation.ValidatePolicyPermissions(tc.haves, tc.wants) if result != tc.expected { t.Errorf("Expected %v, got %v for case: %s", tc.expected, result, tc.name) } @@ -203,28 +167,13 @@ func TestVerifyPermissions_SuperWithOtherPermissions(t *testing.T) { } } -// Benchmark tests -func BenchmarkContains(b *testing.B) { - permissions := []data.PolicyPermission{ - data.PermissionRead, - data.PermissionWrite, - data.PermissionList, - data.PermissionSuper, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - contains(permissions, data.PermissionWrite) - } -} - func BenchmarkVerifyPermissions_WithSuper(b *testing.B) { haves := []data.PolicyPermission{data.PermissionSuper} wants := []data.PolicyPermission{data.PermissionRead, data.PermissionWrite, data.PermissionList} b.ResetTimer() for i := 0; i < b.N; i++ { - verifyPermissions(haves, wants) + validation.ValidatePolicyPermissions(haves, wants) } } @@ -234,7 +183,7 @@ func BenchmarkVerifyPermissions_WithoutSuper(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - verifyPermissions(haves, wants) + validation.ValidatePolicyPermissions(haves, wants) } } @@ -250,6 +199,6 @@ func BenchmarkVerifyPermissions_LargePermissionSet(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - verifyPermissions(haves, wants) + validation.ValidatePolicyPermissions(haves, wants) } } diff --git a/app/nexus/internal/state/persist/init.go b/app/nexus/internal/state/persist/init.go index 794d014c..d199302c 100644 --- a/app/nexus/internal/state/persist/init.go +++ b/app/nexus/internal/state/persist/init.go @@ -5,39 +5,11 @@ package persist import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "github.com/spiffe/spike-sdk-go/config/env" "github.com/spiffe/spike-sdk-go/crypto" - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/security/mem" + "github.com/spiffe/spike-sdk-go/validation" ) -func createCipher() cipher.AEAD { - key := make([]byte, crypto.AES256KeySize) // AES-256 key - if _, randErr := rand.Read(key); randErr != nil { - log.FatalLn("createCipher", "message", - "Failed to generate test key", "err", randErr) - } - - block, cipherErr := aes.NewCipher(key) - if cipherErr != nil { - log.FatalLn("createCipher", "message", - "Failed to create cipher", "err", cipherErr) - } - - gcm, gcmErr := cipher.NewGCM(block) - if gcmErr != nil { - log.FatalLn("createCipher", "message", - "Failed to create GCM", "err", gcmErr) - } - - return gcm -} - // InitializeBackend creates and returns a backend storage implementation based // on the configured store type in the environment. The function is thread-safe // through a mutex lock. @@ -62,31 +34,15 @@ func createCipher() cipher.AEAD { // Note: This function modifies the package-level be variable. Later calls // will reinitialize the backend, potentially losing any existing state. func InitializeBackend(rootKey *[crypto.AES256KeySize]byte) { - const fName = "InitializeBackend" - // Root key is not needed, nor used in in-memory stores. // For in-memory stores, ensure that it is always nil, as the alternative // might mean a logic, or initialization-flow bug, and an unnecessary // crypto material in the memory. // In other store types, ensure it is set for security. if env.BackendStoreTypeVal() == env.Memory { - if rootKey != nil { - failErr := *sdkErrors.ErrRootKeyNotEmpty.Clone() - failErr.Msg = "root key should be nil for memory store type" - log.FatalErr(fName, failErr) - } + validation.NilRootKeyOrDie(rootKey) } else { - if rootKey == nil { - failErr := *sdkErrors.ErrRootKeyEmpty.Clone() - failErr.Msg = "root key cannot be nil" - log.FatalErr(fName, failErr) - } - - if mem.Zeroed32(rootKey) { - failErr := *sdkErrors.ErrRootKeyEmpty.Clone() - failErr.Msg = "root key cannot be empty" - log.FatalErr(fName, failErr) - } + validation.ValidRootKeyOrDie(rootKey) } backendMu.Lock() diff --git a/app/nexus/internal/state/persist/init_memory.go b/app/nexus/internal/state/persist/init_memory.go index 032ac49f..3097414a 100644 --- a/app/nexus/internal/state/persist/init_memory.go +++ b/app/nexus/internal/state/persist/init_memory.go @@ -6,6 +6,7 @@ package persist import ( "github.com/spiffe/spike-sdk-go/config/env" + "github.com/spiffe/spike-sdk-go/crypto" "github.com/spiffe/spike/app/nexus/internal/state/backend" "github.com/spiffe/spike/app/nexus/internal/state/backend/memory" @@ -19,5 +20,7 @@ import ( // persistence. This backend is suitable for testing or scenarios where // persistent storage is not required. func initializeInMemoryBackend() backend.Backend { - return memory.NewInMemoryStore(createCipher(), env.MaxSecretVersionsVal()) + return memory.NewInMemoryStore( + crypto.CreateCipher(), env.MaxSecretVersionsVal(), + ) } diff --git a/app/nexus/internal/state/persist/init_sqlite.go b/app/nexus/internal/state/persist/init_sqlite.go index 0193c9e9..9344f9e5 100644 --- a/app/nexus/internal/state/persist/init_sqlite.go +++ b/app/nexus/internal/state/persist/init_sqlite.go @@ -12,7 +12,7 @@ import ( "github.com/spiffe/spike-sdk-go/config/fs" sdkErrors "github.com/spiffe/spike-sdk-go/errors" "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/security/mem" + "github.com/spiffe/spike-sdk-go/validation" "github.com/spiffe/spike/app/nexus/internal/state/backend" "github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite" @@ -44,19 +44,7 @@ func initializeSqliteBackend(rootKey *[32]byte) backend.Backend { const fName = "initializeSqliteBackend" const dbName = "spike.db" - // TODO: this is repeated, maybe move to the SDK. - - if rootKey == nil { - failErr := *sdkErrors.ErrRootKeyEmpty.Clone() - failErr.Msg = "root key cannot be nil" - log.FatalErr(fName, failErr) - } - - if mem.Zeroed32(rootKey) { - failErr := *sdkErrors.ErrRootKeyEmpty.Clone() - failErr.Msg = "root key cannot be empty" - log.FatalErr(fName, failErr) - } + validation.ValidRootKeyOrDie(rootKey) opts := map[backend.DatabaseConfigKey]any{} diff --git a/app/spike/internal/cmd/cipher/decrypt.go b/app/spike/internal/cmd/cipher/decrypt.go index a048bd0f..3128ae93 100644 --- a/app/spike/internal/cmd/cipher/decrypt.go +++ b/app/spike/internal/cmd/cipher/decrypt.go @@ -8,8 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" sdk "github.com/spiffe/spike-sdk-go/api" - - "github.com/spiffe/spike/app/spike/internal/trust" + "github.com/spiffe/spike-sdk-go/spiffeid" ) // newDecryptCommand creates a Cobra command for decrypting data via SPIKE @@ -49,7 +48,7 @@ func newDecryptCommand( Use: "decrypt", Short: "Decrypt file or stdin via SPIKE Nexus", Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) + spiffeid.IsPilotOperatorOrDie(SPIFFEID) if source == nil { cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") diff --git a/app/spike/internal/cmd/cipher/encrypt.go b/app/spike/internal/cmd/cipher/encrypt.go index f69160b4..3925cd97 100644 --- a/app/spike/internal/cmd/cipher/encrypt.go +++ b/app/spike/internal/cmd/cipher/encrypt.go @@ -8,8 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" sdk "github.com/spiffe/spike-sdk-go/api" - - "github.com/spiffe/spike/app/spike/internal/trust" + "github.com/spiffe/spike-sdk-go/spiffeid" ) // newEncryptCommand creates a Cobra command for encrypting data via SPIKE @@ -46,7 +45,7 @@ func newEncryptCommand( Use: "encrypt", Short: "Encrypt file or stdin via SPIKE Nexus", Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) + spiffeid.IsPilotOperatorOrDie(SPIFFEID) if source == nil { cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") diff --git a/app/spike/internal/cmd/operator/recover.go b/app/spike/internal/cmd/operator/recover.go index 4273a188..67dc435c 100644 --- a/app/spike/internal/cmd/operator/recover.go +++ b/app/spike/internal/cmd/operator/recover.go @@ -19,8 +19,6 @@ import ( "github.com/spiffe/spike-sdk-go/config/fs" "github.com/spiffe/spike-sdk-go/security/mem" "github.com/spiffe/spike-sdk-go/spiffeid" - - "github.com/spiffe/spike/app/spike/internal/trust" ) // newOperatorRecoverCommand creates a new cobra command for recovery operations @@ -61,13 +59,7 @@ func newOperatorRecoverCommand( Use: "recover", Short: "Recover SPIKE Nexus (do this while SPIKE Nexus is healthy)", Run: func(cmd *cobra.Command, args []string) { - if !spiffeid.IsPilotRecover(SPIFFEID) { - cmd.PrintErrln("Error: You need the 'recover' role.") - cmd.PrintErrln("See https://spike.ist/operations/recovery/") - return - } - - trust.AuthenticateForPilotRecover(SPIFFEID) + spiffeid.IsPilotRecoverOrDie(SPIFFEID) if source == nil { cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") diff --git a/app/spike/internal/cmd/operator/restore.go b/app/spike/internal/cmd/operator/restore.go index 8a943d0e..ca87566e 100644 --- a/app/spike/internal/cmd/operator/restore.go +++ b/app/spike/internal/cmd/operator/restore.go @@ -18,8 +18,6 @@ import ( "github.com/spiffe/spike-sdk-go/security/mem" "github.com/spiffe/spike-sdk-go/spiffeid" "golang.org/x/term" - - "github.com/spiffe/spike/app/spike/internal/trust" ) // newOperatorRestoreCommand creates a new cobra command for restoration @@ -63,18 +61,7 @@ func newOperatorRestoreCommand( Use: "restore", Short: "Restore SPIKE Nexus (do this if SPIKE Nexus cannot auto-recover)", Run: func(cmd *cobra.Command, args []string) { - if !spiffeid.IsPilotRestore(SPIFFEID) { - cmd.PrintErrln("Error: You need the 'restore' role.") - cmd.PrintErrln("See https://spike.ist/operations/recovery/") - return - } - - trust.AuthenticateForPilotRestore(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotRestoreOrDie(SPIFFEID) cmd.Println("(your input will be hidden as you paste/type it)") cmd.Print("Enter recovery shard: ") diff --git a/app/spike/internal/cmd/policy/apply.go b/app/spike/internal/cmd/policy/apply.go index b67e2022..70c58091 100644 --- a/app/spike/internal/cmd/policy/apply.go +++ b/app/spike/internal/cmd/policy/apply.go @@ -11,9 +11,9 @@ import ( "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" "github.com/spiffe/spike-sdk-go/api/entity/data" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newPolicyApplyCommand creates a new Cobra command for policy application. @@ -117,12 +117,7 @@ func newPolicyApplyCommand( Valid permissions: read, write, list, super`, Args: cobra.NoArgs, Run: func(c *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - c.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/policy/create.go b/app/spike/internal/cmd/policy/create.go index e93ce315..e5a89a86 100644 --- a/app/spike/internal/cmd/policy/create.go +++ b/app/spike/internal/cmd/policy/create.go @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newPolicyCreateCommand creates a new Cobra command for policy creation. @@ -89,12 +89,7 @@ func newPolicyCreateCommand( Valid permissions: read, write, list, super`, Args: cobra.NoArgs, Run: func(c *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - c.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/policy/delete.go b/app/spike/internal/cmd/policy/delete.go index d59c9ef5..94c62cdf 100644 --- a/app/spike/internal/cmd/policy/delete.go +++ b/app/spike/internal/cmd/policy/delete.go @@ -13,9 +13,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newPolicyDeleteCommand creates a new Cobra command for policy deletion. @@ -80,12 +80,7 @@ func newPolicyDeleteCommand( - A policy name with the --name flag: spike policy delete --name=my-policy`, Run: func(c *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - c.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/policy/format.go b/app/spike/internal/cmd/policy/format.go index 772ec437..f45c6b60 100644 --- a/app/spike/internal/cmd/policy/format.go +++ b/app/spike/internal/cmd/policy/format.go @@ -30,7 +30,9 @@ import ( // // Returns: // - string: The formatted output or error message -func formatPoliciesOutput(cmd *cobra.Command, policies *[]data.PolicyListItem) string { +func formatPoliciesOutput( + cmd *cobra.Command, policies *[]data.PolicyListItem, +) string { format, _ := cmd.Flags().GetString("format") // Validate format @@ -69,13 +71,19 @@ func formatPoliciesOutput(cmd *cobra.Command, policies *[]data.PolicyListItem) s result.WriteString("POLICIES\n========\n\n") // Tabwriter header - fmt.Fprintln(tw, "ID\tNAME") + _, fmtErr := fmt.Fprintln(tw, "ID\tNAME") + if fmtErr != nil { + return "Error: Failed to write to output: " + fmtErr.Error() + } for _, policy := range *policies { - fmt.Fprintf(tw, "%s\t%s\n", policy.ID, policy.Name) + _, writeErr := fmt.Fprintf(tw, "%s\t%s\n", policy.ID, policy.Name) + if writeErr != nil { + return "Error: Failed to write to output: " + writeErr.Error() + } } - if err := tw.Flush(); err != nil { - return fmt.Sprintf("Error: failed to flush tabwriter output: %v\n", err) + if flushErr := tw.Flush(); flushErr != nil { + return fmt.Sprintf("Error: failed to flush tabwriter output: %v\n", flushErr) } return result.String() diff --git a/app/spike/internal/cmd/policy/get.go b/app/spike/internal/cmd/policy/get.go index d1bcbc0d..9a8b58a2 100644 --- a/app/spike/internal/cmd/policy/get.go +++ b/app/spike/internal/cmd/policy/get.go @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newPolicyGetCommand creates a new Cobra command for retrieving policy @@ -101,12 +101,7 @@ func newPolicyGetCommand( Use --format=json to get the output in JSON format.`, Run: func(c *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - c.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/policy/list.go b/app/spike/internal/cmd/policy/list.go index bac95d6a..a3097696 100644 --- a/app/spike/internal/cmd/policy/list.go +++ b/app/spike/internal/cmd/policy/list.go @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newPolicyListCommand creates a new Cobra command for listing policies. @@ -107,12 +107,7 @@ func newPolicyListCommand( "SPIFFE ID pattern", Args: cobra.NoArgs, Run: func(c *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - c.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/policy/test_helper.go b/app/spike/internal/cmd/policy/test_helper.go index 5dbef9c1..9ae6bf66 100644 --- a/app/spike/internal/cmd/policy/test_helper.go +++ b/app/spike/internal/cmd/policy/test_helper.go @@ -54,7 +54,10 @@ func normalizePolicyOutput(output string) string { name = fields[1] } - fmt.Fprintf(&b, "ID: %s, Name: %s\n", id, name) + _, printErr := fmt.Fprintf(&b, "ID: %s, Name: %s\n", id, name) + if printErr != nil { + return "" + } } return b.String() diff --git a/app/spike/internal/cmd/secret/delete.go b/app/spike/internal/cmd/secret/delete.go index d352c3f3..dc4df493 100644 --- a/app/spike/internal/cmd/secret/delete.go +++ b/app/spike/internal/cmd/secret/delete.go @@ -12,9 +12,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretDeleteCommand creates and returns a new cobra.Command for deleting @@ -67,12 +67,7 @@ Examples: # Deletes current version plus versions 1 and 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/secret/get.go b/app/spike/internal/cmd/secret/get.go index 1422eeef..05ffba21 100644 --- a/app/spike/internal/cmd/secret/get.go +++ b/app/spike/internal/cmd/secret/get.go @@ -12,10 +12,10 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "gopkg.in/yaml.v3" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretGetCommand creates and returns a new cobra.Command for retrieving @@ -58,12 +58,7 @@ func newSecretGetCommand( Short: "Get secrets from the specified path", Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/secret/list.go b/app/spike/internal/cmd/secret/list.go index e18e631d..93a3e487 100644 --- a/app/spike/internal/cmd/secret/list.go +++ b/app/spike/internal/cmd/secret/list.go @@ -10,9 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretListCommand creates and returns a new cobra.Command for listing all @@ -48,12 +48,7 @@ func newSecretListCommand( Use: "list", Short: "List all secret paths", Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/secret/metadata_get.go b/app/spike/internal/cmd/secret/metadata_get.go index beeb64e9..f14afe86 100644 --- a/app/spike/internal/cmd/secret/metadata_get.go +++ b/app/spike/internal/cmd/secret/metadata_get.go @@ -10,9 +10,8 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" - + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretMetadataGetCommand creates and returns a new cobra.Command for @@ -57,12 +56,7 @@ func newSecretMetadataGetCommand( Short: "Gets secret metadata from the specified path", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/secret/put.go b/app/spike/internal/cmd/secret/put.go index 759b3f6d..6a60e234 100644 --- a/app/spike/internal/cmd/secret/put.go +++ b/app/spike/internal/cmd/secret/put.go @@ -11,9 +11,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretPutCommand creates and returns a new cobra.Command for storing @@ -60,12 +60,7 @@ func newSecretPutCommand( Short: "Put secrets at the specified path", Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/cmd/secret/undelete.go b/app/spike/internal/cmd/secret/undelete.go index b79dd64c..884fd303 100644 --- a/app/spike/internal/cmd/secret/undelete.go +++ b/app/spike/internal/cmd/secret/undelete.go @@ -12,9 +12,9 @@ import ( "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" + "github.com/spiffe/spike-sdk-go/spiffeid" "github.com/spiffe/spike/app/spike/internal/stdout" - "github.com/spiffe/spike/app/spike/internal/trust" ) // newSecretUndeleteCommand creates and returns a new cobra.Command for @@ -65,12 +65,7 @@ Examples: # Undeletes current version plus versions 1 and 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - trust.AuthenticateForPilot(SPIFFEID) - - if source == nil { - cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.") - return - } + spiffeid.IsPilotOperatorOrDie(SPIFFEID) api := spike.NewWithSource(source) diff --git a/app/spike/internal/trust/spiffeid.go b/app/spike/internal/trust/spiffeid.go deleted file mode 100644 index 17ed7f64..00000000 --- a/app/spike/internal/trust/spiffeid.go +++ /dev/null @@ -1,54 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -// Package trust provides functions and utilities to manage and validate trust -// relationships using the SPIFFE standard. This package includes methods for -// authenticating SPIFFE IDs, ensuring secure identity verification in -// distributed systems. -package trust - -import ( - sdkErrors "github.com/spiffe/spike-sdk-go/errors" - "github.com/spiffe/spike-sdk-go/log" - "github.com/spiffe/spike-sdk-go/spiffeid" -) - -// AuthenticateForPilot verifies if the provided SPIFFE ID belongs to a -// SPIKE Pilot instance. Logs a fatal error and exits if verification fails. -// -// SPIFFEID is the SPIFFE ID string to authenticate for pilot access. -func AuthenticateForPilot(SPIFFEID string) { - const fName = "AuthenticateForPilot" - if !spiffeid.IsPilotOperator(SPIFFEID) { - failErr := *sdkErrors.ErrAccessUnauthorized.Clone() - failErr.Msg = "you need a 'pilot' SPIFFE ID to use this command" - log.FatalErr(fName, failErr) - } -} - -// AuthenticateForPilotRecover validates the SPIFFE ID for the recover role -// and exits the application if it does not match the recover SPIFFE ID. -// -// SPIFFEID is the SPIFFE ID string to authenticate for pilot recover access. -func AuthenticateForPilotRecover(SPIFFEID string) { - const fName = "AuthenticateForPilotRecover" - if !spiffeid.IsPilotRecover(SPIFFEID) { - failErr := *sdkErrors.ErrAccessUnauthorized.Clone() - failErr.Msg = "you need a 'recover' SPIFFE ID to use this command" - log.FatalErr(fName, failErr) - } -} - -// AuthenticateForPilotRestore verifies if the given SPIFFE ID is valid for -// restoration. Logs a fatal error and exits if the SPIFFE ID validation fails. -// -// SPIFFEID is the SPIFFE ID string to authenticate for restore access. -func AuthenticateForPilotRestore(SPIFFEID string) { - const fName = "AuthenticateForPilotRestore" - if !spiffeid.IsPilotRestore(SPIFFEID) { - failErr := *sdkErrors.ErrAccessUnauthorized.Clone() - failErr.Msg = "you need a 'restore' SPIFFE ID to use this command" - log.FatalErr(fName, failErr) - } -} diff --git a/app/spike/internal/trust/spiffeid_test.go b/app/spike/internal/trust/spiffeid_test.go deleted file mode 100644 index 2e49a5cc..00000000 --- a/app/spike/internal/trust/spiffeid_test.go +++ /dev/null @@ -1,129 +0,0 @@ -// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ -// \\\\\ Copyright 2024-present SPIKE contributors. -// \\\\\\\ SPDX-License-Identifier: Apache-2.0 - -package trust - -import ( - "testing" -) - -// The trust module uses environment variables to determine the trust root. -// The default is "spike.ist" if SPIKE_TRUST_ROOT_PILOT is not set. -// The expected SPIFFE ID patterns are (based on SDK spiffeid module): -// - Pilot: spiffe:///spike/pilot/role/superuser -// - Recover: spiffe:///spike/pilot/role/recover -// - Restore: spiffe:///spike/pilot/role/restore - -func TestAuthenticateForPilot_ValidPilotID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot - t.Setenv("SPIKE_TRUST_ROOT_PILOT", "spike.ist") - - // Use the pilot SPIFFE ID pattern (includes role/superuser) - pilotID := "spiffe://spike.ist/spike/pilot/role/superuser" - - defer func() { - if r := recover(); r != nil { - t.Errorf("AuthenticateForPilot() panicked for valid pilot ID: %v", r) - } - }() - - AuthenticateForPilot(pilotID) -} - -func TestAuthenticateForPilot_InvalidID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot - t.Setenv("SPIKE_TRUST_ROOT_PILOT", "spike.ist") - - // Use an invalid SPIFFE ID - invalidID := "spiffe://spike.ist/some/other/workload" - - defer func() { - if r := recover(); r == nil { - t.Error("AuthenticateForPilot() should have panicked for invalid ID") - } - }() - - AuthenticateForPilot(invalidID) - t.Error("Should have panicked before reaching here") -} - -func TestAuthenticateForPilotRecover_ValidRecoverID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot recover - t.Setenv("SPIKE_TRUST_ROOT_PILOT_RECOVER", "spike.ist") - - // Use the recover SPIFFE ID pattern - recoverID := "spiffe://spike.ist/spike/pilot/role/recover" - - defer func() { - if r := recover(); r != nil { - t.Errorf("AuthenticateForPilotRecover() panicked for valid ID: %v", r) - } - }() - - AuthenticateForPilotRecover(recoverID) -} - -func TestAuthenticateForPilotRecover_InvalidID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot recover - t.Setenv("SPIKE_TRUST_ROOT_PILOT_RECOVER", "spike.ist") - - // Use an invalid SPIFFE ID (pilot, not pilot/role/recover) - invalidID := "spiffe://spike.ist/spike/pilot" - - defer func() { - if r := recover(); r == nil { - t.Error("AuthenticateForPilotRecover() should have panicked " + - "for invalid ID") - } - }() - - AuthenticateForPilotRecover(invalidID) - t.Error("Should have panicked before reaching here") -} - -func TestAuthenticateForPilotRestore_ValidRestoreID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot restore - t.Setenv("SPIKE_TRUST_ROOT_PILOT_RESTORE", "spike.ist") - - // Use the restore SPIFFE ID pattern - restoreID := "spiffe://spike.ist/spike/pilot/role/restore" - - defer func() { - if r := recover(); r != nil { - t.Errorf("AuthenticateForPilotRestore() panicked for valid ID: %v", r) - } - }() - - AuthenticateForPilotRestore(restoreID) -} - -func TestAuthenticateForPilotRestore_InvalidID(t *testing.T) { - // Enable panic on FatalErr for testing - t.Setenv("SPIKE_STACK_TRACES_ON_LOG_FATAL", "true") - // Set trust root for pilot restore - t.Setenv("SPIKE_TRUST_ROOT_PILOT_RESTORE", "spike.ist") - - // Use an invalid SPIFFE ID (pilot, not pilot/role/restore) - invalidID := "spiffe://spike.ist/spike/pilot" - - defer func() { - if r := recover(); r == nil { - t.Error("AuthenticateForPilotRestore() should have panicked " + - "for invalid ID") - } - }() - - AuthenticateForPilotRestore(invalidID) - t.Error("Should have panicked before reaching here") -} diff --git a/future/004-spike-multi-tenancy.md b/future/004-spike-multi-tenancy.md new file mode 100644 index 00000000..0365120f --- /dev/null +++ b/future/004-spike-multi-tenancy.md @@ -0,0 +1 @@ +See https://github.com/spiffe/spike/issues/281 \ No newline at end of file diff --git a/go.mod b/go.mod index 22f4eb23..b82eca5a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.19.1 + github.com/spiffe/spike-sdk-go v0.19.7 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 95abe9ac..3a5b64ce 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.19.1 h1:aY/xCqIZgMJ0B/hYVhqepm3wGXM8xZKvBzb+ieW4kCw= -github.com/spiffe/spike-sdk-go v0.19.1/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.19.7 h1:JQ8GxyLnCzJRfWeqDknj2+HKblKIeNdnkopYccDPnj4= +github.com/spiffe/spike-sdk-go v0.19.7/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= From a3486178f202a8c16ed70fff8ff0c92589a7f9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20=C3=96z=C3=A7elik?= Date: Tue, 20 Jan 2026 04:39:45 +0000 Subject: [PATCH 28/28] WIP. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Volkan Özçelik --- .../initialization/recovery/keeper.go | 2 +- go.mod | 2 +- go.sum | 4 +- hack/scm/cleanup-branches.sh | 153 ++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100755 hack/scm/cleanup-branches.sh diff --git a/app/nexus/internal/initialization/recovery/keeper.go b/app/nexus/internal/initialization/recovery/keeper.go index e378f7c5..744fa8c4 100644 --- a/app/nexus/internal/initialization/recovery/keeper.go +++ b/app/nexus/internal/initialization/recovery/keeper.go @@ -85,7 +85,7 @@ func iterateKeepersAndInitializeState( "id", keeperID, "url", keeperAPIRoot, ) - u := url.ShardFromKeperAPIRoot(keeperAPIRoot) + u := url.ShardFromKeeperAPIRoot(keeperAPIRoot) data, err := shardGetResponse(source, u) if err != nil { warnErr := sdkErrors.ErrNetPeerConnection.Wrap(err) diff --git a/go.mod b/go.mod index b82eca5a..d2e01721 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.33 github.com/spf13/cobra v1.10.2 github.com/spiffe/go-spiffe/v2 v2.6.0 - github.com/spiffe/spike-sdk-go v0.19.7 + github.com/spiffe/spike-sdk-go v0.19.9 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.0 diff --git a/go.sum b/go.sum index 3a5b64ce..ce91f344 100644 --- a/go.sum +++ b/go.sum @@ -126,8 +126,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= -github.com/spiffe/spike-sdk-go v0.19.7 h1:JQ8GxyLnCzJRfWeqDknj2+HKblKIeNdnkopYccDPnj4= -github.com/spiffe/spike-sdk-go v0.19.7/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= +github.com/spiffe/spike-sdk-go v0.19.9 h1:Y/w6LkjOjJqLoOKoCpEj2iW2EIid2fLO01BsRbbMM5o= +github.com/spiffe/spike-sdk-go v0.19.9/go.mod h1:+L57h7oL9cVax9Z9UB/QVYTz9b6t2NT+5fIpgBIZ3zs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/hack/scm/cleanup-branches.sh b/hack/scm/cleanup-branches.sh new file mode 100755 index 00000000..88167587 --- /dev/null +++ b/hack/scm/cleanup-branches.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash + +# \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/ +# \\\\\ Copyright 2024-present SPIKE contributors. +# \\\\\\\ SPDX-License-Identifier: Apache-2.0 + +# This script deletes local branches that have already been merged to upstream. +# +# Usage: ./hack/scm/cleanup-branches.sh [--dry-run] +# +# Options: +# --dry-run Show which branches would be deleted without actually deleting them +# +# The script performs the following steps: +# 1. Fetches the latest changes from origin +# 2. Identifies branches that have been merged to the main branch +# 3. Deletes merged branches (excluding main, master, and current branch) + +set -euo pipefail + +# Color codes for output +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly NC='\033[0m' # No Color + +# Protected branches that should never be deleted +readonly PROTECTED_BRANCHES=("main" "master" "develop" "dev") + +DRY_RUN=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + echo "Usage: $0 [--dry-run]" + echo "" + echo "Options:" + echo " --dry-run Show which branches would be deleted without actually deleting them" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ SPIKE SDK Go - Merged Branch Cleanup ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}" +echo "" + +if [ "$DRY_RUN" = true ]; then + echo -e "${YELLOW}Running in DRY-RUN mode - no branches will be deleted${NC}" + echo "" +fi + +# Get current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +echo -e "${BLUE}Current branch: ${CURRENT_BRANCH}${NC}" +echo "" + +# Step 1: Fetch latest from origin +echo -e "${YELLOW}Fetching latest changes from origin...${NC}" +git fetch origin --prune +echo -e "${GREEN}Done fetching.${NC}" +echo "" + +# Step 2: Get merged branches +echo -e "${YELLOW}Finding branches merged to origin/main...${NC}" +echo "" + +# Get list of merged branches (excluding HEAD and remote tracking refs) +MERGED_BRANCHES=$(git branch --merged origin/main --format='%(refname:short)' 2>/dev/null || true) + +if [ -z "$MERGED_BRANCHES" ]; then + echo -e "${GREEN}No merged branches found.${NC}" + exit 0 +fi + +# Function to check if a branch is protected +is_protected() { + local branch=$1 + for protected in "${PROTECTED_BRANCHES[@]}"; do + if [ "$branch" = "$protected" ]; then + return 0 + fi + done + return 1 +} + +# Step 3: Delete merged branches +DELETED_COUNT=0 +SKIPPED_COUNT=0 + +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +for branch in $MERGED_BRANCHES; do + # Skip if it's the current branch + if [ "$branch" = "$CURRENT_BRANCH" ]; then + echo -e "${YELLOW}Skipping current branch: ${branch}${NC}" + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + continue + fi + + # Skip if it's a protected branch + if is_protected "$branch"; then + echo -e "${YELLOW}Skipping protected branch: ${branch}${NC}" + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + continue + fi + + # Delete or show the branch + if [ "$DRY_RUN" = true ]; then + echo -e "${BLUE}Would delete: ${branch}${NC}" + else + if git branch -d "$branch" 2>/dev/null; then + echo -e "${GREEN}Deleted: ${branch}${NC}" + else + # Try force delete if regular delete fails + echo -e "${YELLOW}Branch ${branch} requires force delete, skipping (use git branch -D manually if needed)${NC}" + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + continue + fi + fi + DELETED_COUNT=$((DELETED_COUNT + 1)) +done + +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" + +# Summary +echo -e "${BLUE}Summary:${NC}" +if [ "$DRY_RUN" = true ]; then + echo -e " Branches to delete: ${GREEN}${DELETED_COUNT}${NC}" +else + echo -e " Branches deleted: ${GREEN}${DELETED_COUNT}${NC}" +fi +echo -e " Branches skipped: ${YELLOW}${SKIPPED_COUNT}${NC}" +echo "" + +if [ "$DRY_RUN" = true ] && [ "$DELETED_COUNT" -gt 0 ]; then + echo -e "${YELLOW}Run without --dry-run to delete these branches.${NC}" +fi + +echo -e "${GREEN}Done!${NC}"