diff --git a/app/bootstrap/cmd/main.go b/app/bootstrap/cmd/main.go index 1b16ad0c..2b1b4f57 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,21 +16,17 @@ 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" ) 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). @@ -71,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 264b3dea..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() @@ -107,7 +101,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) } @@ -118,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 @@ -140,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/bootstrap/internal/net/broadcast.go b/app/bootstrap/internal/net/broadcast.go index 448de056..c59987e2 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 @@ -210,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" @@ -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..edf3bb30 --- /dev/null +++ b/app/bootstrap/internal/net/dispatch.go @@ -0,0 +1,81 @@ +// \\ 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" +) + +// 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) + + // 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, + func() (bool, *sdkErrors.SDKError) { + log.Info(fName, + "message", "sending shard to keeper", + "keeper_id", keeperID, + "keeper_url", keeperURL, + ) + + 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( + 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/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/cmd/main.go b/app/keeper/cmd/main.go index 12cb60e0..a825cec0 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" @@ -48,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/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/keeper/internal/route/store/contribute.go b/app/keeper/internal/route/store/contribute.go index 95148f5e..d3b42e9e 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 @@ -62,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/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.go b/app/keeper/internal/route/store/shard.go index c0d07ce5..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[ @@ -81,6 +80,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/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/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/cmd/main.go b/app/nexus/cmd/main.go index 00d04d9b..3c50f93b 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 ( @@ -78,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/initialization.go b/app/nexus/internal/initialization/initialization.go index 5994ff2c..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), @@ -62,9 +60,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", @@ -80,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/initialization/recovery/keeper.go b/app/nexus/internal/initialization/recovery/keeper.go index 3c6c94da..744fa8c4 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" @@ -67,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 { @@ -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.ShardFromKeeperAPIRoot(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/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/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/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/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/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 80b2b635..c77a5f20 100644 --- a/app/nexus/internal/route/acl/policy/delete_intercept.go +++ b/app/nexus/internal/route/acl/policy/delete_intercept.go @@ -7,12 +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" - 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" ) @@ -40,40 +38,16 @@ 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 { - return err - } - - policyID := request.ID - - 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 + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyDeleteResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyDelete, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } - allowed := state.CheckAccess( - peerSPIFFEID.String(), cfg.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionWrite}, + return net.RespondErrOnBadPolicyID( + request.ID, w, reqres.PolicyDeleteResponse{}.BadRequest(), ) - 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 } diff --git a/app/nexus/internal/route/acl/policy/get.go b/app/nexus/internal/route/acl/policy/get.go index abce19be..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" ) @@ -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 8da93c06..1e958389 100644 --- a/app/nexus/internal/route/acl/policy/get_intercept.go +++ b/app/nexus/internal/route/acl/policy/get_intercept.go @@ -7,12 +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/validation" + "github.com/spiffe/spike-sdk-go/predicate" state "github.com/spiffe/spike/app/nexus/internal/state/base" ) @@ -40,40 +38,16 @@ 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(), - ) - 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 + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyReadResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyRead, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } - allowed := state.CheckAccess( - peerSPIFFEID.String(), apiAuth.PathSystemPolicyAccess, - []data.PolicyPermission{data.PermissionRead}, + return net.RespondErrOnBadPolicyID( + request.ID, w, reqres.PolicyReadResponse{}.BadRequest(), ) - if !allowed { - failErr := net.Fail( - reqres.PolicyReadResponse{}.Unauthorized(), w, http.StatusUnauthorized, - ) - if failErr != nil { - return sdkErrors.ErrAccessUnauthorized.Wrap(failErr) - } - return sdkErrors.ErrAccessUnauthorized.Clone() - } - - return nil } diff --git a/app/nexus/internal/route/acl/policy/list.go b/app/nexus/internal/route/acl/policy/list.go index f87e8ed0..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" ) @@ -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 6df79791..499f52b0 100644 --- a/app/nexus/internal/route/acl/policy/list_intercept.go +++ b/app/nexus/internal/route/acl/policy/list_intercept.go @@ -7,22 +7,14 @@ 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}, - ) -} - // guardListPolicyRequest validates a policy list request by performing // authentication and authorization checks. // @@ -45,6 +37,14 @@ func hasListPermission(peerSPIFFEID string) bool { func guardListPolicyRequest( _ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.RespondUnauthorizedOnPredicateFail(hasListPermission, - reqres.PolicyListResponse{}.Unauthorized(), w, r) + 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/acl/policy/put.go b/app/nexus/internal/route/acl/policy/put.go index 5aadb126..a4623b34 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" ) @@ -65,16 +65,13 @@ func RoutePutPolicy( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "RoutePutPolicy" - journal.AuditRequest(fName, r, audit, journal.AuditCreate) - request, err := net.ReadParseAndGuard[ - reqres.PolicyPutRequest, reqres.PolicyPutResponse, - ]( - w, r, reqres.PolicyPutResponse{}.BadRequest(), guardPolicyCreateRequest, + 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 f0df2181..aa14ea86 100644 --- a/app/nexus/internal/route/acl/policy/put_intercept.go +++ b/app/nexus/internal/route/acl/policy/put_intercept.go @@ -7,24 +7,15 @@ 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 +// guardPolicyPutRequest validates a policy creation request by performing // authentication, authorization, and input validation checks. // // The function performs the following validations in order: @@ -47,72 +38,37 @@ func hasWritePermission(peerSPIFFEID string) bool { // - 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.RespondUnauthorizedOnPredicateFail( - hasWritePermission, - reqres.PolicyPutResponse{}.Unauthorized(), w, r, - ) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.PolicyPutResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForPolicyWrite, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } - 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 nameErr := net.RespondErrOnBadName( + request.Name, reqres.PolicyPutResponse{}.BadRequest(), w, + ); nameErr != nil { + return nameErr } - 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() + if spifeIdPatternErr := net.RespondErrOnBadSPIFFEIDPattern( + request.SPIFFEIDPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ); spifeIdPatternErr != nil { + return spifeIdPatternErr } - 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() + if pathPatternErr := net.RespondErrOnBadPathPattern( + request.PathPattern, reqres.PolicyPutResponse{}.BadRequest(), w, + ); pathPatternErr != nil { + return pathPatternErr } - 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() - } - } - - return nil + return net.RespondErrOnBadPermission( + request.Permissions, reqres.PolicyPutResponse{}.BadRequest(), w, + ) } diff --git a/app/nexus/internal/route/base/guard_test.go b/app/nexus/internal/route/base/guard_test.go index 61c42708..1125a668 100644 --- a/app/nexus/internal/route/base/guard_test.go +++ b/app/nexus/internal/route/base/guard_test.go @@ -115,10 +115,8 @@ 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", "handle.go", "net.go", "state.go", 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.go b/app/nexus/internal/route/bootstrap/verify.go index 52156e9a..bfaf2df2 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" ) @@ -56,22 +56,21 @@ func RouteVerify( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "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 +79,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 809236d8..188c56f3 100644 --- a/app/nexus/internal/route/bootstrap/verify_intercept.go +++ b/app/nexus/internal/route/bootstrap/verify_intercept.go @@ -8,18 +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" - - "github.com/spiffe/spike-sdk-go/crypto" ) -// 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. // @@ -49,38 +42,27 @@ 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 - } - - 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() + // No CheckAccess because this route is privileged and should not honor + // policy overrides. Match exact SPIFFE ID instead. + if authErr := net.AuthorizeAndRespondOnFailNoPolicy( + reqres.BootstrapVerifyResponse{}.Unauthorized(), + spiffeid.IsBootstrap, + w, r, + ); 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/config.go b/app/nexus/internal/route/cipher/config.go deleted file mode 100644 index 7ba4bf28..00000000 --- a/app/nexus/internal/route/cipher/config.go +++ /dev/null @@ -1,15 +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 "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/decrypt_intercept.go b/app/nexus/internal/route/cipher/decrypt_intercept.go index e0ae1991..1f465b5f 100644 --- a/app/nexus/internal/route/cipher/decrypt_intercept.go +++ b/app/nexus/internal/route/cipher/decrypt_intercept.go @@ -7,19 +7,14 @@ 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" 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" ) -// 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: @@ -45,56 +40,34 @@ import ( // - nil if all validations pass // - apiErr.ErrUnauthorized if authorization fails // - apiErr.ErrBadInput if request validation fails -func guardDecryptCipherRequest( - request reqres.CipherDecryptRequest, - peerSPIFFEID *spiffeid.ID, - w http.ResponseWriter, - _ *http.Request, +func guardCipherDecryptRequest( + request reqres.CipherDecryptRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr + } + // Validate version - if err := validateVersion( + if versionErr := net.RespondCryptoErrOnVersionMismatch( request.Version, w, reqres.CipherDecryptResponse{}.BadRequest(), - ); err != nil { - return err + ); versionErr != nil { + return versionErr } // Validate nonce size - if err := validateNonceSize( + 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 := validateCiphertextSize( + return 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 + ) } diff --git a/app/nexus/internal/route/cipher/doc.go b/app/nexus/internal/route/cipher/doc.go index 982319a6..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 @@ -99,7 +98,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.go b/app/nexus/internal/route/cipher/encrypt.go index 16b5e787..34871645 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" ) @@ -55,26 +56,13 @@ func RouteEncrypt( w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry, ) *sdkErrors.SDKError { const fName = "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/encrypt_intercept.go b/app/nexus/internal/route/cipher/encrypt_intercept.go index 9109f1c2..39877a98 100644 --- a/app/nexus/internal/route/cipher/encrypt_intercept.go +++ b/app/nexus/internal/route/cipher/encrypt_intercept.go @@ -7,19 +7,14 @@ 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" 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" ) -// 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: @@ -45,42 +40,22 @@ import ( // - nil if all validations pass // - apiErr.ErrUnauthorized if authorization fails // - apiErr.ErrBadInput if request validation fails -func guardEncryptCipherRequest( +func guardCipherEncryptRequest( 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( - request.Plaintext, w, reqres.CipherEncryptResponse{}.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.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() + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherEncryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherEncrypt, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } - return nil + // 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 ed7d35bc..7d99331e 100644 --- a/app/nexus/internal/route/cipher/handle.go +++ b/app/nexus/internal/route/cipher/handle.go @@ -10,6 +10,10 @@ 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 @@ -31,14 +35,17 @@ 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. - // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) - if err != nil { - return err + if authErr := net.AuthorizeAndRespondOnFail( + reqres.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } // Get cipher only after SPIFFE ID validation passes @@ -48,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 { @@ -63,17 +70,17 @@ func handleStreamingDecrypt( } // Full guard validation (auth and request fields) - guardErr := guardDecryptCipherRequest(request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(request, w, r) if guardErr != nil { return guardErr } - plaintext, decryptErr := decryptDataStreaming(nonce, ciphertext, c, w) + plaintext, decryptErr := net.DecryptDataStreaming(nonce, ciphertext, c, w) if decryptErr != nil { return decryptErr } - return respondStreamingDecrypt(plaintext, w) + return net.RespondStreamingDecrypt(plaintext, w) } // handleJSONDecrypt processes a complete JSON mode decryption request, @@ -95,19 +102,23 @@ func handleJSONDecrypt( 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.CipherDecryptResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForCipherDecrypt, + state.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr } // Parse request (doesn't need cipher) - request, readErr := readJSONDecryptRequestWithoutGuard(w, r) + request, readErr := net.ReadJSONDecryptRequestWithoutGuard(w, r) if readErr != nil { return readErr } // Full guard validation (auth and request fields) - guardErr := guardDecryptCipherRequest(*request, peerSPIFFEID, w, r) + guardErr := guardCipherDecryptRequest(*request, w, r) if guardErr != nil { return guardErr } @@ -118,14 +129,14 @@ func handleJSONDecrypt( return cipherErr } - plaintext, decryptErr := decryptDataJSON( + plaintext, decryptErr := net.DecryptDataJSON( request.Nonce, request.Ciphertext, c, w, ) if decryptErr != nil { return decryptErr } - return respondJSONDecrypt(plaintext, w) + return net.RespondJSONDecrypt(plaintext, w) } // handleStreamingEncrypt processes a complete streaming mode encryption @@ -146,41 +157,23 @@ func handleStreamingEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) + req, err := net.ReadAndGuardRequest( + net.ReadStreamingEncryptRequestWithoutGuard, + guardCipherEncryptRequest, + w, r, + ) if err != nil { return err } - // Read plaintext (doesn't need cipher) - plaintext, readErr := readStreamingEncryptRequestWithoutGuard(w, r) - if readErr != nil { - return readErr - } - - // Construct request object for guard validation - request := reqres.CipherEncryptRequest{ - Plaintext: plaintext, - } - - // Full guard validation (auth and request fields) - guardErr := guardEncryptCipherRequest(request, peerSPIFFEID, w, r) - if guardErr != nil { - return guardErr - } - - // Get cipher only after auth passes - c, cipherErr := getCipher() - if cipherErr != nil { - return cipherErr - } - - nonce, ciphertext, encryptErr := encryptDataStreaming(plaintext, c, w) + nonce, ciphertext, encryptErr := net.GetCipherAndEncrypt( + getCipher, net.EncryptDataStreaming, req.Plaintext, w, + ) if encryptErr != nil { return encryptErr } - return respondStreamingEncrypt(nonce, ciphertext, w) + return net.RespondStreamingEncrypt(nonce, ciphertext, w) } // handleJSONEncrypt processes a complete JSON mode encryption request, @@ -201,36 +194,21 @@ func handleJSONEncrypt( w http.ResponseWriter, r *http.Request, getCipher func() (cipher.AEAD, *sdkErrors.SDKError), ) *sdkErrors.SDKError { - // Extract and validate SPIFFE ID before accessing cipher - peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r) + req, err := net.ReadAndGuardRequest( + net.ReadJSONEncryptRequestWithoutGuard, + guardCipherEncryptRequest, + w, r, + ) if err != nil { return err } - // Parse request (doesn't need cipher) - request, jsonErr := readJSONEncryptRequestWithoutGuard(w, r) - if jsonErr != nil { - return jsonErr - } - - // Full guard validation (auth and request fields) - guardErr := guardEncryptCipherRequest(*request, peerSPIFFEID, w, r) - if guardErr != nil { - return guardErr - } - - // Get cipher only after auth passes - c, cipherErr := getCipher() - if cipherErr != nil { - return cipherErr - } - - nonce, ciphertext, encryptErr := encryptDataJSON( - request.Plaintext, c, 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 7734951f..00000000 --- a/app/nexus/internal/route/cipher/read.go +++ /dev/null @@ -1,172 +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. -// -// Parameters: -// - w: The HTTP response writer for error responses -// - r: The HTTP request containing the binary data -// -// Returns: -// - plaintext: The plaintext data to encrypt -// - *sdkErrors.SDKError: An error if reading fails -func readStreamingEncryptRequestWithoutGuard( - w http.ResponseWriter, r *http.Request, -) ([]byte, *sdkErrors.SDKError) { - plaintext, err := net.ReadRequestBodyAndRespondOnFail(w, r) - if err != nil { - return nil, err - } - - return 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 e7d4c7b3..00000000 --- a/app/nexus/internal/route/cipher/state.go +++ /dev/null @@ -1,69 +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" - "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/cipher/validation.go b/app/nexus/internal/route/cipher/validation.go deleted file mode 100644 index 863a8615..00000000 --- a/app/nexus/internal/route/cipher/validation.go +++ /dev/null @@ -1,134 +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/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 -} 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/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/operator/recover.go b/app/nexus/internal/route/operator/recover.go index 20576cbe..8b22feef 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" ) @@ -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/recover_intercept.go b/app/nexus/internal/route/operator/recover_intercept.go index c6524c67..09e81c54 100644 --- a/app/nexus/internal/route/operator/recover_intercept.go +++ b/app/nexus/internal/route/operator/recover_intercept.go @@ -42,7 +42,15 @@ import ( func guardRecoverRequest( _ reqres.RecoverRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRecover, - reqres.RestoreResponse{}.Unauthorized(), w, r, - ) + // No CheckAccess because this route is privileged and should not honor + // policy overrides. Match exact SPIFFE ID instead. + 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 74d42f78..f1ece31f 100644 --- a/app/nexus/internal/route/operator/restore.go +++ b/app/nexus/internal/route/operator/restore.go @@ -11,17 +11,18 @@ 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/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" ) var ( - shards []recovery.ShamirShard + shards []crypto.ShamirShard shardsMutex sync.RWMutex ) @@ -60,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 { @@ -115,7 +115,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/restore_intercept.go b/app/nexus/internal/route/operator/restore_intercept.go index 18f9ce1b..ca2f1d73 100644 --- a/app/nexus/internal/route/operator/restore_intercept.go +++ b/app/nexus/internal/route/operator/restore_intercept.go @@ -8,7 +8,6 @@ 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" @@ -46,39 +45,23 @@ import ( func guardRestoreRequest( request reqres.RestoreRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRestore, - reqres.RestoreResponse{}.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 authErr := net.AuthorizeAndRespondOnFailNoPolicy( + reqres.RestoreResponse{}.Unauthorized(), + spiffeid.IsPilotRestore, + w, r, + ); authErr != nil { + return authErr } - // TODO: magic number: 1 - if request.ID < 1 || request.ID > env.ShamirMaxShareCountVal() { - failErr := net.Fail( - reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest, - ) - if failErr != nil { - return sdkErrors.ErrAPIBadRequest.Wrap(failErr) - } - return sdkErrors.ErrAPIBadRequest + 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 - } - 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/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 { diff --git a/app/nexus/internal/route/secret/delete.go b/app/nexus/internal/route/secret/delete.go index 6850ba2a..b6bf5784 100644 --- a/app/nexus/internal/route/secret/delete.go +++ b/app/nexus/internal/route/secret/delete.go @@ -9,9 +9,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/journal" + "github.com/spiffe/spike-sdk-go/net" 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 7495799e..b3f75f49 100644 --- a/app/nexus/internal/route/secret/delete_intercept.go +++ b/app/nexus/internal/route/secret/delete_intercept.go @@ -7,9 +7,12 @@ 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 +40,17 @@ import ( func guardDeleteSecretRequest( request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return guardSecretRequest( + if authErr := net.AuthorizeAndRespondOnFailForPath( + reqres.SecretDeleteResponse{}.Unauthorized(), request.Path, - []data.PolicyPermission{data.PermissionWrite}, + predicate.AllowSPIFFEIDForSecretDelete, + state.CheckPolicyAccess, w, r, - reqres.SecretDeleteResponse{}.Unauthorized(), - reqres.SecretDeleteResponse{}.BadRequest(), + ); authErr != nil { + return authErr + } + + return net.RespondErrOnBadPath( + request.Path, reqres.SecretDeleteResponse{}.BadRequest(), w, ) } 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/get_intercept.go b/app/nexus/internal/route/secret/get_intercept.go index 785f9460..eff3a2b2 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,17 @@ import ( func guardGetSecretRequest( request reqres.SecretGetRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - return guardSecretRequest( + if authErr := net.AuthorizeAndRespondOnFailForPath( + reqres.SecretGetResponse{}.Unauthorized(), request.Path, - []data.PolicyPermission{data.PermissionRead}, + predicate.AllowSPIFFEIDForSecretRead, + state.CheckPolicyAccess, w, r, - reqres.SecretGetResponse{}.Unauthorized(), - reqres.SecretGetResponse{}.BadRequest(), + ); 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 6ae4f9cb..00000000 --- a/app/nexus/internal/route/secret/guard.go +++ /dev/null @@ -1,79 +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" - 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" -) - -// 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 - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[TUnauth]( - r, w, 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 - } - - // 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 -} 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/list_intercept.go b/app/nexus/internal/route/secret/list_intercept.go index b9688b4b..9a800503 100644 --- a/app/nexus/internal/route/secret/list_intercept.go +++ b/app/nexus/internal/route/secret/list_intercept.go @@ -7,11 +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" - 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,27 +41,10 @@ import ( func guardListSecretRequest( _ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request, ) *sdkErrors.SDKError { - peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.SecretListResponse]( - r, w, reqres.SecretListResponse{}.Unauthorized(), + return net.AuthorizeAndRespondOnFail( + reqres.SecretListResponse{}.Unauthorized(), + predicate.AllowSPIFFEIDForSecretList, + state.CheckPolicyAccess, + w, r, ) - 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/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/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 05956df5..ebcde67f 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" ) @@ -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[ @@ -101,5 +100,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/nexus/internal/route/secret/metadata_get_intercept.go b/app/nexus/internal/route/secret/metadata_get_intercept.go index 6cf685c1..50be9fde 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.CheckPolicyAccess, + 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.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/put_intercept.go b/app/nexus/internal/route/secret/put_intercept.go index 4be39c1a..89546489 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.CheckPolicyAccess, + 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.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 501b6976..c26d2010 100644 --- a/app/nexus/internal/route/secret/undelete_intercept.go +++ b/app/nexus/internal/route/secret/undelete_intercept.go @@ -7,9 +7,12 @@ 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 +44,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.CheckPolicyAccess, + w, r, + ); authErr != nil { + return authErr + } + + return net.RespondErrOnBadPath( + request.Path, reqres.SecretUndeleteResponse{}.BadRequest(), w, ) } 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/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/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 2a420214..49ea7473 100644 --- a/app/nexus/internal/state/base/policy.go +++ b/app/nexus/internal/state/base/policy.go @@ -13,16 +13,22 @@ 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-sdk-go/validation" "github.com/spiffe/spike/app/nexus/internal/state/persist" ) -// 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. +// 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. +// +// 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 +// CheckPolicyAccess 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,22 +40,15 @@ 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. -func CheckAccess( +// A policy matches when its SPIFFE ID pattern matches the requestor's ID and +// its path pattern matches the requested path. +func CheckPolicyAccess( 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. - if spiffeid.IsPilotOperator(peerSPIFFEID) { - return true - } + const fName = "CheckPolicyAccess" policies, err := ListPolicies() if err != nil { @@ -68,7 +67,7 @@ func CheckAccess( 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/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/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/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.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/cipher/encrypt_impl.go b/app/spike/internal/cmd/cipher/encrypt_impl.go index 01584078..c2c947dc 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" @@ -39,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 { @@ -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..67dc435c 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" @@ -18,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 @@ -60,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.") @@ -75,7 +68,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..ca87566e 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" @@ -17,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 @@ -62,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: ") @@ -144,7 +132,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..70c58091 100644 --- a/app/spike/internal/cmd/policy/apply.go +++ b/app/spike/internal/cmd/policy/apply.go @@ -5,13 +5,15 @@ package policy import ( + "context" + "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/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. @@ -115,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) @@ -163,8 +160,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..e5a89a86 100644 --- a/app/spike/internal/cmd/policy/create.go +++ b/app/spike/internal/cmd/policy/create.go @@ -5,12 +5,14 @@ package policy import ( + "context" + "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. @@ -87,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) @@ -136,8 +133,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..94c62cdf 100644 --- a/app/spike/internal/cmd/policy/delete.go +++ b/app/spike/internal/cmd/policy/delete.go @@ -6,15 +6,16 @@ package policy import ( "bufio" + "context" "os" "strings" "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. @@ -79,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) @@ -105,7 +101,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/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 1b05d93c..9a8b58a2 100644 --- a/app/spike/internal/cmd/policy/get.go +++ b/app/spike/internal/cmd/policy/get.go @@ -5,12 +5,14 @@ package policy import ( + "context" + "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 @@ -99,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) @@ -113,7 +110,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..a3097696 100644 --- a/app/spike/internal/cmd/policy/list.go +++ b/app/spike/internal/cmd/policy/list.go @@ -5,12 +5,14 @@ package policy import ( + "context" + "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. @@ -105,16 +107,13 @@ 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) - 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/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/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..dc4df493 100644 --- a/app/spike/internal/cmd/secret/delete.go +++ b/app/spike/internal/cmd/secret/delete.go @@ -5,15 +5,16 @@ package secret import ( + "context" "strconv" "strings" "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 @@ -66,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) @@ -113,7 +109,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..05ffba21 100644 --- a/app/spike/internal/cmd/secret/get.go +++ b/app/spike/internal/cmd/secret/get.go @@ -5,16 +5,17 @@ package secret import ( + "context" "encoding/json" "slices" "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 @@ -57,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) @@ -81,7 +77,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..93a3e487 100644 --- a/app/spike/internal/cmd/secret/list.go +++ b/app/spike/internal/cmd/secret/list.go @@ -5,12 +5,14 @@ package secret import ( + "context" + "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 @@ -46,16 +48,13 @@ 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) - 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..f14afe86 100644 --- a/app/spike/internal/cmd/secret/metadata_get.go +++ b/app/spike/internal/cmd/secret/metadata_get.go @@ -5,12 +5,13 @@ package secret import ( + "context" + "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 @@ -55,19 +56,16 @@ 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) 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..6a60e234 100644 --- a/app/spike/internal/cmd/secret/put.go +++ b/app/spike/internal/cmd/secret/put.go @@ -5,14 +5,15 @@ package secret import ( + "context" "strings" "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 @@ -59,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) @@ -91,7 +87,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..884fd303 100644 --- a/app/spike/internal/cmd/secret/undelete.go +++ b/app/spike/internal/cmd/secret/undelete.go @@ -5,15 +5,16 @@ package secret import ( + "context" "strconv" "strings" "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 @@ -64,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) @@ -104,7 +100,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/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/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/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 34677614..d2e01721 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.19 - golang.org/x/term v0.38.0 + 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 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 b3e9ffed..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.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.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= @@ -155,14 +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= @@ -181,21 +181,21 @@ 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/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +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.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= 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= @@ -203,8 +203,8 @@ 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= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 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}" 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) - } -} 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.