// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"crypto/fips140"
"flag"
"time"
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/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/internal/config"
)
const appName = "SPIKE Bootstrap"
func main() {
log.Info(
appName,
"message", "starting",
"version", config.BootstrapVersion,
)
// Hard timeout for the entire bootstrap process.
// A value of 0 means no timeout (infinite).
bootstrapTimeout := env.BootstrapTimeoutVal()
if bootstrapTimeout > 0 {
log.Debug(
appName,
"message", "bootstrap timeout configured",
"timeout", bootstrapTimeout.String(),
)
time.AfterFunc(bootstrapTimeout, func() {
log.FatalLn(
appName,
"message", "bootstrap timeout exceeded, terminating",
"timeout", bootstrapTimeout.String(),
)
})
}
init := flag.Bool("init", false, "Initialize the bootstrap module")
flag.Parse()
if !*init {
failErr := *sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "invalid command line arguments: usage: boostrap -init"
log.FatalErr(appName, failErr)
return
}
// 0. Skip bootstrap for Lite backend and In-Memory backend
// 1. Else, if SPIKE_BOOTSTRAP_FORCE="true", always proceed (return true)
// 2. In bare-metal environments (non-Kubernetes), always proceed
// 3. In Kubernetes environments, check the "spike-bootstrap-state"
// ConfigMap:
// - If ConfigMap exists and bootstrap-completed="true", skip bootstrap
// - Otherwise, proceed with bootstrap
skip := !lifecycle.ShouldBootstrap()
if skip {
log.Warn(appName, "message", "skipping bootstrap")
return
}
log.Info(
appName,
"message", "FIPS 140.3 Status",
"enabled", fips140.Enabled(),
)
// Panics if it cannot acquire the source.
src := net.AcquireSource()
log.Info(appName, "message", "sending shards to SPIKE Keeper instances")
api := spike.NewWithSource(src)
defer func() {
closeErr := api.Close()
if closeErr == nil {
return
}
warnErr := sdkErrors.ErrFSStreamCloseFailed.Wrap(closeErr)
warnErr.Msg = "failed to close SPIKE API client"
log.WarnErr(appName, *warnErr)
}()
ctx := context.Background()
// Broadcast shards to the SPIKE keepers until all shards are
// dispatched successfully.
net.BroadcastKeepers(ctx, api)
log.Info(appName, "message", "sent shards to SPIKE Keeper instances")
// Verify that SPIKE Nexus has been properly initialized by sending an
// encrypted payload and verifying the hash of the decrypted plaintext.
// Retries verification until successful.
net.VerifyInitialization(ctx, api)
// Clear the seed after use.
state.LockRootKeySeed()
defer state.UnlockRootKeySeed()
mem.ClearRawBytes(state.RootKeySeedNoLock())
// Bootstrap verification is complete. Mark the bootstrap as "done".
// Mark completion in Kubernetes
if err := lifecycle.MarkBootstrapComplete(); err != nil {
warnErr := sdkErrors.ErrK8sReconciliationFailed.Wrap(err)
warnErr.Msg = "failed to mark bootstrap complete in ConfigMap"
log.WarnErr(appName, *warnErr)
}
log.Info("bootstrap completed successfully")
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package lifecycle provides utilities for managing bootstrap state in
// Kubernetes environments. It handles coordination between multiple bootstrap
// instances to ensure bootstrap operations run exactly once per cluster.
package lifecycle
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
k8s "k8s.io/api/core/v1"
k8sMeta "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
const k8sTrue = "true"
const k8sServiceAccountNamespace = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
const hostNameEnvVar = "HOSTNAME"
const keyBootstrapCompleted = "bootstrap-completed"
const keyBootstrapCompletedAt = "completed-at"
const keyBootstrapCompletedByPod = "completed-by-pod"
// ShouldBootstrap determines whether the bootstrap process should be
// skipped based on the current environment and state. The function follows
// this decision logic:
//
// 0. Skip for Lite backend and In-Memory backend
// 1. Else, if SPIKE_BOOTSTRAP_FORCE="true", always proceed (return true)
// 2. In bare-metal environments (non-Kubernetes), always proceed
// 3. In Kubernetes environments, check the "spike-bootstrap-state" ConfigMap:
// - If ConfigMap exists and bootstrap-completed="true", skip bootstrap
// - Otherwise, proceed with bootstrap
//
// The function returns false if the bootstrap operation should be skipped,
// true if it should proceed.
func ShouldBootstrap() bool {
const fName = "ShouldBootstrap"
// Memory backend doesn't need bootstrap.
if env.BackendStoreTypeVal() == env.Memory {
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",
)
return false
}
// Check if we're forcing the bootstrap
if os.Getenv(env.BootstrapForce) == k8sTrue {
log.Info(fName, "message", "force bootstrap enabled")
return true
}
// Try to detect if we're running in Kubernetes
// InClusterConfig looks for:
// - KUBERNETES_SERVICE_HOST env var
// - /var/run/secrets/kubernetes.io/serviceaccount/token
cfg, cfgErr := rest.InClusterConfig()
if cfgErr != nil {
// We're not in Kubernetes (bare-metal scenario)
// Bootstrap should proceed in non-k8s environments
if errors.Is(cfgErr, rest.ErrNotInCluster) {
log.Info(
fName,
"message",
"not running in Kubernetes: proceeding with bootstrap",
)
return true
}
// Some other error. Skip bootstrap.
failErr := sdkErrors.ErrK8sClientFailed.Clone()
failErr.Msg = "failed to get Kubernetes config: skipping bootstrap"
log.WarnErr(fName, *failErr)
return false
}
// We're in Kubernetes---check the ConfigMap
clientset, clientErr := kubernetes.NewForConfig(cfg)
if clientErr != nil {
failErr := sdkErrors.ErrK8sClientFailed.Clone()
failErr.Msg = "failed to create Kubernetes client: skipping bootstrap"
log.WarnErr(fName, *failErr)
// Can't check state, skip bootstrap.
return false
}
namespace := "spike"
// Read namespace from the service account if not specified
if nsBytes, readErr := os.ReadFile(k8sServiceAccountNamespace); readErr == nil {
namespace = string(nsBytes)
}
cm, getErr := clientset.CoreV1().ConfigMaps(namespace).Get(
context.Background(),
env.BootstrapConfigMapNameVal(),
k8sMeta.GetOptions{},
)
if getErr != nil {
failErr := sdkErrors.ErrK8sReconciliationFailed.Wrap(getErr)
// 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
}
bootstrapCompleted := cm.Data[keyBootstrapCompleted] == k8sTrue
completedAt := cm.Data[keyBootstrapCompletedAt]
completedByPod := cm.Data[keyBootstrapCompletedByPod]
if bootstrapCompleted {
reason := fmt.Sprintf(
"completed at %s by pod %s",
completedAt, completedByPod,
)
log.Info(
fName,
"message", "skipping bootstrap based on ConfigMap state",
keyBootstrapCompletedAt, completedAt,
keyBootstrapCompletedByPod, completedByPod,
"reason", reason,
)
return false
}
// Bootstrap is not completed: proceed with bootstrap
return true
}
// MarkBootstrapComplete creates or updates the "spike-bootstrap-state"
// ConfigMap in Kubernetes to mark the bootstrap process as successfully
// completed. The ConfigMap includes:
//
// - bootstrap-completed: "true"
// - completed-at: RFC3339 timestamp
// - completed-by-pod: hostname of the pod that completed bootstrap
//
// This function only operates in Kubernetes environments. In bare-metal
// deployments, it logs a message and returns nil without error.
//
// If the ConfigMap already exists, it will be updated. If creation fails,
// an update operation is attempted as a fallback.
func MarkBootstrapComplete() *sdkErrors.SDKError {
const fName = "MarkBootstrapComplete"
// Only mark complete in Kubernetes environments
config, cfgErr := rest.InClusterConfig()
if cfgErr != nil {
if errors.Is(cfgErr, rest.ErrNotInCluster) {
// Not in Kubernetes, nothing to mark
log.Info(
fName,
"message", "not in Kubernetes: skipping completion marker",
)
return nil
}
failErr := sdkErrors.ErrK8sReconciliationFailed.Clone()
failErr.Msg = "failed to get Kubernetes config"
return failErr.Wrap(cfgErr)
}
clientset, clientErr := kubernetes.NewForConfig(config)
if clientErr != nil {
failErr := sdkErrors.ErrK8sReconciliationFailed.Clone()
failErr.Msg = "failed to create Kubernetes client"
return failErr.Wrap(clientErr)
}
namespace := "spike"
if nsBytes, readErr := os.ReadFile(
k8sServiceAccountNamespace,
); readErr == nil {
namespace = string(nsBytes)
} else {
failErr := sdkErrors.ErrK8sReconciliationFailed.Wrap(readErr)
failErr.Msg = "failed to read service account namespace: using default: " +
namespace
log.WarnErr(fName, *failErr)
}
// Create ConfigMap marking bootstrap as complete
cm := &k8s.ConfigMap{
ObjectMeta: k8sMeta.ObjectMeta{
Name: env.BootstrapConfigMapNameVal(),
},
Data: map[string]string{
keyBootstrapCompleted: k8sTrue,
keyBootstrapCompletedAt: time.Now().UTC().Format(time.RFC3339),
keyBootstrapCompletedByPod: os.Getenv(hostNameEnvVar),
},
}
ctx := context.Background()
_, createErr := clientset.CoreV1().ConfigMaps(
namespace,
).Create(ctx, cm, k8sMeta.CreateOptions{})
if createErr != nil {
// Try to update if it already exists
_, updateErr := clientset.CoreV1().ConfigMaps(
namespace,
).Update(ctx, cm, k8sMeta.UpdateOptions{})
if updateErr != nil {
failErr := sdkErrors.ErrK8sReconciliationFailed.Wrap(updateErr)
failErr.Msg = "failed to mark bootstrap complete in ConfigMap"
return failErr
}
}
log.Info(
fName,
"message", "marked bootstrap as complete in ConfigMap",
)
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"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"
"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/retry"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/spiffe"
svid "github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/app/bootstrap/internal/state"
)
// BroadcastKeepers distributes root key shares to all configured SPIKE
// Keeper instances. It iterates through each keeper ID from the
// environment configuration and sends the corresponding keeper share
// using the provided API. The function retries with exponential
// backoff using a configurable timeout and a maximum number of retry
// attempts per keeper. If a keeper cannot be reached within the
// timeout, the function terminates with a clear error message,
// allowing the operator to fix the issue and rerun bootstrap.
//
// This fail-fast approach is appropriate for bootstrap because:
// - Bootstrap is a day-zero operation with an active operator watching
// - Bootstrap is idempotent and safe to rerun after fixing issues
// - Clear error messages help operators quickly identify and fix
// problems
// - Blocking forever obscures problems rather than surfacing them
//
// 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"
validation.NonNilContextOrDie(ctx, fName)
// Ensures the number of keepers matches the Shamir shares required.
keepers := env.KeepersVal()
expectedShares := env.ShamirSharesVal()
if len(keepers) != expectedShares {
failErr := sdkErrors.ErrShamirNotEnoughKeepers.Clone()
failErr.Msg = fmt.Sprintf(
"keeper count mismatch: SPIKE_NEXUS_SHAMIR_SHARES=%d "+
"but %d keepers configured in SPIKE_NEXUS_KEEPER_PEERS; "+
"these values must match",
expectedShares, len(keepers),
)
log.FatalErr(fName, *failErr)
return
}
state.LockRootKeySeed()
defer state.UnlockRootKeySeed()
// RootShares() generates the root key and splits it into shares.
// It enforces single-call semantics and will terminate if called again.
rks := state.RootKeySeedNoLock()
rs := crypto.RootShares(rks)
timeout := env.BootstrapKeeperTimeoutVal()
maxRetries := env.BootstrapKeeperMaxRetriesVal()
for keeperID, keeperURL := range env.KeepersVal() {
err := broadcastToKeeper(
ctx, api, rs, keeperID, keeperURL, timeout, maxRetries,
)
if err != nil {
failErr := sdkErrors.ErrBootstrapKeeperUnreachable.Clone()
failErr.Msg = fmt.Sprintf(
"failed to reach keeper %s at %s after %d attempts; "+
"ensure all keepers are running and rerun bootstrap",
keeperID, keeperURL, maxRetries,
)
log.FatalErr(fName, *failErr)
return
}
}
}
// 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
// key, and sends the plaintext along with the nonce and ciphertext to SPIKE
// Nexus for verification. The function retries indefinitely until the
// verification succeeds. It terminates the application if any cryptographic
// operations fail.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - api: SPIKE API client for verification requests
func VerifyInitialization(ctx context.Context, api *spike.API) {
const fName = "VerifyInitialization"
validation.NonNilContextOrDie(ctx, fName)
// Generate random text for verification
randomBytes := make([]byte, 32)
_, err := rand.Read(randomBytes)
if err != nil {
failErr := sdkErrors.ErrCryptoRandomGenerationFailed.Wrap(err)
log.FatalErr(fName, *failErr)
return
}
randomText := hex.EncodeToString(randomBytes)
state.LockRootKeySeed()
defer state.UnlockRootKeySeed()
// Encrypt the random text with the canonical root key (NOT the raw seed).
// SPIKE Keepers/Nexus key their cipher with the root key derived by
// reducing the seed into a P256 scalar (ComputeShares) and marshalling it
// back to bytes; encrypting with the raw seed would mismatch Nexus's
// cipher and the verification would always fail. Use the no-lock accessor:
// LockRootKeySeed already holds the write lock, and RootKeySeed would take
// a read lock on the same non-reentrant RWMutex, deadlocking.
seed := state.RootKeySeedNoLock()
rootSecret, _ := crypto.ComputeShares(seed)
rootKey, marshalErr := rootSecret.MarshalBinary()
if marshalErr != nil {
failErr := sdkErrors.ErrDataMarshalFailure.Wrap(marshalErr)
failErr.Msg = "failed to marshal root key for verification"
log.FatalErr(fName, *failErr)
return
}
defer mem.ClearBytes(rootKey)
block, aesErr := aes.NewCipher(rootKey)
if aesErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateCipher.Wrap(aesErr)
log.FatalErr(fName, *failErr)
return
}
gcm, gcmErr := cipher.NewGCM(block)
if gcmErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateGCM.Wrap(gcmErr)
log.FatalErr(fName, *failErr)
return
}
nonce := make([]byte, gcm.NonceSize())
if _, nonceErr := io.ReadFull(rand.Reader, nonce); nonceErr != nil {
failErr := sdkErrors.ErrCryptoFailedToReadNonce.Wrap(nonceErr)
log.FatalErr(fName, *failErr)
return
}
ciphertext := gcm.Seal(nil, nonce, []byte(randomText), nil)
// At this point we talk to SPIKE Nexus, and our expectation is SPIKE Nexus
// is about to become healthy. So, we retry with a reasonable timeout and
// 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)
if verifyErr != nil {
failErr := sdkErrors.ErrCryptoCipherVerificationFailed.Wrap(verifyErr)
failErr.Msg = "failed to verify initialization: will retry"
log.WarnErr(fName, *failErr)
return false, failErr
}
return true, nil
}, retry.WithBackOffOptions(
retry.WithMaxElapsedTime(env.BootstrapInitVerificationTimeoutVal())),
)
if retryErr != nil {
failErr := sdkErrors.ErrCryptoCipherVerificationFailed.Wrap(retryErr)
failErr.Msg = "failed to verify initialization within timeout"
log.FatalErr(fName, *failErr)
}
}
// AcquireSource obtains and validates an X.509 SVID source with a SPIKE
// Bootstrap SPIFFE ID. The function retrieves the X.509 SVID from the SPIFFE
// Workload API and verifies that the SPIFFE ID matches the expected ID pattern.
// If the SVID cannot be obtained or does not have the required bootstrap
// SPIFFE ID, the function terminates the application. This function is used
// to ensure that only authorized SPIKE Bootstrap workloads can perform
// initialization operations.
//
// Returns:
// - *workloadapi.X509Source: The validated X.509 SVID source, or nil if
// acquisition fails (the function terminates the application on failure)
func AcquireSource() *workloadapi.X509Source {
const fName = "AcquireSource"
ctx, cancel := context.WithTimeout(
context.Background(),
env.SPIFFESourceTimeoutVal(),
)
defer cancel()
src, spiffeID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
log.FatalErr(fName, *err)
return nil
}
if !svid.IsBootstrap(spiffeID) {
failErr := *sdkErrors.ErrAccessUnauthorized.Clone()
failErr.Msg = "bootstrap SPIFFE ID required"
log.FatalErr(fName, failErr)
return nil
}
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()
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package state
import (
"fmt"
"strconv"
"github.com/cloudflare/circl/group"
shamir "github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// RootKeySeed returns a pointer to the root key seed used for encryption.
// This key is generated when RootShares() is called and persists in memory
// for the duration of the bootstrap process. This function acquires a read
// lock to ensure thread-safe access to the root key seed.
//
// Returns:
// - *[32]byte: Pointer to the root key seed
func RootKeySeed() *[crypto.AES256KeySize]byte {
rootKeySeedMu.RLock()
defer rootKeySeedMu.RUnlock()
return &rootKeySeed
}
// RootKeySeedNoLock returns a pointer to the root key seed without acquiring
// any lock. The caller must hold the lock via LockRootKeySeed before calling
// this function and release it via UnlockRootKeySeed when done.
//
// This function exists to support patterns where the caller needs to perform
// multiple operations on the root key seed atomically, or when using defer
// in a loop would cause resource leaks.
//
// Returns:
// - *[32]byte: Pointer to the root key seed
func RootKeySeedNoLock() *[crypto.AES256KeySize]byte {
return &rootKeySeed
}
// LockRootKeySeed acquires an exclusive write lock on the root key seed.
// This must be paired with UnlockRootKeySeed to release the lock.
//
// Use this in combination with RootKeySeedNoLock when you need explicit lock
// control, such as avoiding defer in loops or performing multiple operations
// atomically.
func LockRootKeySeed() {
rootKeySeedMu.Lock()
}
// UnlockRootKeySeed releases the exclusive write lock on the root key seed.
// This must be called after LockRootKeySeed to avoid deadlocks.
func UnlockRootKeySeed() {
rootKeySeedMu.Unlock()
}
// KeeperShare finds and returns the secret share corresponding to a specific
// Keeper ID. It searches through the provided root shares to locate the share
// with an ID matching the given keeperID (converted from string to integer).
// The function uses P256 scalar comparison to match share IDs with the Keeper
// identifier.
//
// Security behavior:
// The application will crash (via log.FatalErr) if:
// - The keeperID cannot be converted to an integer
// - No matching share is found for the specified keeper ID
//
// Parameters:
// - rootShares: The Shamir secret shares to search through
// - keeperID: The string identifier of the keeper (must be numeric)
//
// Returns:
// - shamir.Share: The share corresponding to the keeper ID
func KeeperShare(
rootShares []shamir.Share, keeperID string,
) shamir.Share {
const fName = "keeperShare"
var share shamir.Share
for _, sr := range rootShares {
kid, err := strconv.Atoi(keeperID)
if err != nil {
failErr := sdkErrors.ErrShamirInvalidIndex.Wrap(err)
failErr.Msg = fmt.Sprintf(
"failed to convert keeper ID to int: '%s'", keeperID,
)
log.FatalErr(fName, *failErr)
}
if sr.ID.IsEqual(group.P256.NewScalar().SetUint64(uint64(kid))) {
share = sr
break
}
}
if share.ID.IsZero() {
failErr := sdkErrors.ErrShamirInvalidIndex.Clone()
failErr.Msg = fmt.Sprintf("no share found for keeper ID: '%s'", keeperID)
log.FatalErr(fName, *failErr)
}
return share
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package state
import (
"testing"
"github.com/cloudflare/circl/group"
shamir "github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/spike-sdk-go/crypto"
)
// createTestShares creates test shares with known structure for testing
// KeeperShare and related functionality.
func createTestShares(t *testing.T, numShares int) []shamir.Share {
g := group.P256
// Create a test secret
secret := g.NewScalar()
testKey := make([]byte, crypto.AES256KeySize)
for i := range testKey {
testKey[i] = byte(i % 256)
}
err := secret.UnmarshalBinary(testKey)
if err != nil {
t.Fatalf("Failed to create test secret: %v", err)
}
// Create shares with threshold = numShares - 1
threshold := uint(numShares - 1)
reader := crypto.NewDeterministicReader(testKey)
ss := shamir.New(reader, threshold, secret)
return ss.Share(uint(numShares))
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"fmt"
spike "github.com/spiffe/spike-sdk-go/api"
)
func main() {
fmt.Println("SPIKE Demo")
// Make sure you register the demo app SPIRE Server registration entry
// first:
// ./examples/consume-secrets/demo-register-entry.sh
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#New
api, connErr := spike.New() // Use the default Workload API Socket
if connErr != nil {
fmt.Println("Error connecting to SPIKE Nexus:", connErr.Error())
return
}
fmt.Println("Connected to SPIKE Nexus.")
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#Close
defer func() {
// Close the connection when done
closeErr := api.Close()
if closeErr != nil {
fmt.Println("Error closing connection:", closeErr.Error())
}
}()
// The path to store/retrieve/update the secret.
path := "tenants/demo/db/creds"
// Create a Secret
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#PutSecret
putErr := api.PutSecret(path, map[string]string{
"username": "SPIKE",
"password": "SPIKE_Rocks",
})
if putErr != nil {
fmt.Println("Error writing secret:", putErr.Error())
return
}
// Read the Secret
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#GetSecret
secret, getErr := api.GetSecret(path)
if getErr != nil {
fmt.Println("Error reading secret:", getErr.Error())
return
}
if secret == nil {
fmt.Println("Secret not found.")
return
}
fmt.Println("Secret found:")
for k, v := range secret.Data {
fmt.Printf("%s: %s\n", k, v)
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
sdkNet "github.com/spiffe/spike-sdk-go/net"
"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"
)
const appName = "SPIKE Keeper"
func main() {
out.Preamble(appName, config.KeeperVersion)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
source, selfSPIFFEID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
log.FatalErr(appName, *sdkErrors.ErrStateInitializationFailed.Wrap(err))
}
defer func() {
closeErr := spiffe.CloseSource(source)
if closeErr != nil {
log.WarnErr(
appName, *sdkErrors.ErrSPIFFEFailedToCloseX509Source.Wrap(closeErr),
)
}
}()
// I should be a SPIKE Keeper.
if !spiffeid.IsKeeper(selfSPIFFEID) {
failErr := *sdkErrors.ErrStateInitializationFailed.Clone()
failErr.Msg = "SPIFFE ID is not valid: " + selfSPIFFEID
log.FatalErr(appName, failErr)
}
log.Info(
appName,
"message", "started service",
"version", config.KeeperVersion,
)
// Serve the app.
sdkNet.ServeWithRoute(
appName,
source,
http.Route,
predicate.AllowKeeperPeer,
env.KeeperTLSPortVal(),
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package base provides the core routing logic for the SPIKE application's
// HTTP server. It dynamically resolves incoming HTTP requests to the
// appropriate handlers based on their URL paths and methods. This package
// ensures flexibility and extensibility in supporting various API actions and
// paths within SPIKE's ecosystem.
package base
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/url"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/journal"
"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
// executing the appropriate handler based on the request path and HTTP method.
// It uses a factory function to create the specific handler for the given URL
// path and HTTP method combination.
//
// Parameters:
// - w: The HTTP ResponseWriter to write the response to
// - r: The HTTP Request containing the client's request details
// - audit: The AuditEntry containing the client's audit information
func Route(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
return net.RouteFactory[url.APIAction](
url.APIURL(r.URL.Path),
url.APIAction(r.URL.Query().Get(url.KeyAPIAction)),
r.Method,
func(a url.APIAction, p url.APIURL) net.Handler {
switch {
// Get a contribution from SPIKE Nexus:
case a == url.ActionDefault && p == url.KeeperContribute:
return store.RouteContribute
// Provide your shard to SPIKE Nexus:
case a == url.ActionDefault && p == url.KeeperShard:
return store.RouteShard
default:
return net.Fallback
}
})(w, r, audit)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package store
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"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/journal"
"github.com/spiffe/spike/app/keeper/internal/state"
)
// RouteContribute handles HTTP requests for the shard contributions in the
// 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
// periodic updates. Unauthorized requests receive a 401 Unauthorized response.
//
// The function expects a shard in the request body. It performs the following
// operations:
// - Reads and validates the request body
// - Validates the peer SPIFFE ID
// - Validates the shard is not nil or all zeros
// - Stores the shard in the system state
// - Logs the operation for auditing purposes
//
// Parameters:
// - w: http.ResponseWriter to write the HTTP response
// - r: *http.Request containing the incoming HTTP request
// - audit: *journal.AuditEntry for tracking the request for auditing
// purposes
//
// Returns:
// - *sdkErrors.SDKError: nil if successful, otherwise one of:
// - ErrDataReadFailure: If request body cannot be read
// - ErrDataParseFailure: If request parsing fails
// - ErrUnauthorized: If peer SPIFFE ID validation fails
// - ErrShamirNilShard: If shard is nil
// - ErrShamirEmptyShard: If shard is all zeros
//
// Example request body:
//
// {
// "shard": "base64EncodedString",
// "keeperId": "uniqueIdentifier"
// }
//
// The function returns a 200 OK status on success, a 401 Unauthorized status
// if the peer is not SPIKE Bootstrap or SPIKE Nexus, or a 400 Bad Request
// status if the shard content is invalid.
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[
reqres.ShardPutRequest, reqres.ShardPutResponse,
](
w, r, reqres.ShardPutResponse{}.BadRequest(), guardShardPutRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
if request.Shard == nil {
failErr := net.Fail(
reqres.ShardPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
if failErr != nil {
return sdkErrors.ErrDataInvalidInput.Wrap(failErr)
}
return sdkErrors.ErrShamirNilShard
}
// Security: Zero out shard before the function exits.
// [1]
defer func() {
mem.ClearRawBytes(request.Shard)
}()
// Ensure the client didn't send an array of all zeros, which would
// indicate invalid input. Since Shard is a fixed-length array in the request,
// clients must send meaningful non-zero data.
if mem.Zeroed32(request.Shard) {
failErr := net.Fail(
reqres.ShardPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
if failErr != nil {
return sdkErrors.ErrDataInvalidInput.Wrap(failErr)
}
return sdkErrors.ErrShamirEmptyShard
}
// `state.SetShard` copies the shard. We can safely reset this one at [1].
state.SetShard(request.Shard)
return net.Success(reqres.ShardPutResponse{}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package store
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"
"github.com/spiffe/spike-sdk-go/spiffeid"
)
// guardShardPutRequest validates that the peer contributing a shard is either
// SPIKE Bootstrap or SPIKE Nexus. This prevents unauthorized modification of
// shard data stored in SPIKE Keeper.
//
// Both SPIKE Bootstrap (during initial setup) and SPIKE Nexus (during periodic
// updates) are authorized to contribute shards to SPIKE Keeper.
//
// Parameters:
// - _ reqres.ShardPutRequest: The request (unused for validation)
// - w http.ResponseWriter: Response writer for error responses
// - r *http.Request: The HTTP request containing peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: ErrAccessUnauthorized if validation fails,
// nil otherwise
func guardShardPutRequest(
_ reqres.ShardPutRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return net.RespondUnauthorizedOnPredicateFail(spiffeid.PeerCanTalkToKeeper,
reqres.ShardPutResponse{}.Unauthorized(), w, r,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package store
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/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/keeper/internal/state"
)
// RouteShard handles HTTP requests to retrieve the stored shard from the
// system. It retrieves the shard from the system state and returns it to the
// requester.
//
// Security:
//
// This endpoint validates that the requesting peer is SPIKE Nexus using SPIFFE
// ID verification. Only SPIKE Nexus is authorized to retrieve shards during
// recovery operations. Unauthorized requests receive a 401 Unauthorized
// response.
//
// Parameters:
// - w: http.ResponseWriter to write the HTTP response
// - r: *http.Request containing the incoming HTTP request
// - audit: *journal.AuditEntry for tracking the request for auditing purposes
//
// Returns:
// - error: nil if successful, otherwise one of:
// - errors.ErrReadFailure if request body cannot be read
// - errors.ErrParseFailure if request parsing fails
// - errors.ErrUnauthorized if peer SPIFFE ID validation fails
// - errors.ErrNotFound if no shard is stored in the system
//
// Response body:
//
// {
// "shard": "base64EncodedString"
// }
//
// The function returns a 200 OK status with the encoded shard on success,
// a 404 Not Found status if no shard exists, or a 401 Unauthorized status
// if the peer is not SPIKE Nexus.
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[
reqres.ShardGetRequest, reqres.ShardGetResponse,
](
w, r, reqres.ShardGetResponse{}.BadRequest(), guardShardGetRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
state.RLockShard()
defer state.RUnlockShard()
// DO NOT reset `sh` after use, as this function does NOT "own" it.
// Treat the value as "read-only".
sh := state.ShardNoSync()
if mem.Zeroed32(sh) {
failErr := net.Fail(
reqres.ShardGetResponse{}.BadRequest(), w, http.StatusBadRequest,
)
if failErr != nil {
return sdkErrors.ErrDataInvalidInput.Wrap(failErr)
}
return sdkErrors.ErrDataInvalidInput
}
responseBody, respondErr := net.SuccessWithResponseBody(
reqres.ShardGetResponse{Shard: sh}.Success(), w,
)
if respondErr != nil {
respondErr.Msg = "failed to return success response"
log.WarnErr(fName, *respondErr)
}
// Security: Reset response body before function exits.
defer func() {
mem.ClearBytes(responseBody)
}()
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package store
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"
"github.com/spiffe/spike-sdk-go/spiffeid"
)
// guardShardGetRequest validates that the peer requesting shard retrieval is
// SPIKE Nexus. This prevents unauthorized access to sensitive shard data
// stored in SPIKE Keeper.
//
// Only SPIKE Nexus is authorized to retrieve shards from SPIKE Keeper during
// recovery operations.
//
// Parameters:
// - _ reqres.ShardGetRequest: The request (unused for validation)
// - w http.ResponseWriter: Response writer for error responses
// - r *http.Request: The HTTP request containing peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: sdkErrors.ErrAccessUnauthorized if validation fails,
// nil otherwise
func guardShardGetRequest(
_ reqres.ShardGetRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsNexus,
reqres.ShardGetResponse{}.Unauthorized(), w, r,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ 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 (
"github.com/spiffe/spike-sdk-go/crypto"
)
// SetShard safely updates the global shard value under a write lock.
// Although the value is a pointer type, it creates a copy. The value `s`
// can be safely erased after calling `SetShard()`.
//
// Parameters:
// - s *[32]byte: Pointer to the new shard value to store
//
// Thread-safe through shardMutex.
func SetShard(s *[crypto.AES256KeySize]byte) {
shardMutex.Lock()
defer shardMutex.Unlock()
zeroed := true
for i := range s {
if s[i] != 0 {
zeroed = false
break
}
}
// Do not reset the shard if the new value is zero.
if zeroed {
return
}
copy(shard[:], s[:])
}
// ShardNoSync returns a pointer to the shard without acquiring any locks.
// Callers must ensure proper synchronization by using RLockShard and
// RUnlockShard when accessing the returned pointer.
func ShardNoSync() *[crypto.AES256KeySize]byte {
return &shard
}
// RLockShard acquires a read lock on the shard mutex.
// This should be paired with a corresponding call to RUnlockShard,
// typically using `defer`.
func RLockShard() {
shardMutex.RLock()
}
// RUnlockShard releases a read lock on the shard mutex.
// This should only be called after a corresponding call to RLockShard.
func RUnlockShard() {
shardMutex.RUnlock()
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package state
// Helper function to reset shard to zero state for testing
func resetShard() {
shardMutex.Lock()
defer shardMutex.Unlock()
for i := range shard {
shard[i] = 0
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
sdkNet "github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/predicate"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/nexus/internal/initialization"
http "github.com/spiffe/spike/app/nexus/internal/route/base"
"github.com/spiffe/spike/internal/config"
"github.com/spiffe/spike/internal/out"
)
const appName = "SPIKE Nexus"
func main() {
out.Preamble(appName, config.NexusVersion)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log.Info(
appName,
"message", "starting",
"spiffe_trust_root", env.TrustRootVal(),
)
source, selfSPIFFEID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
failErr := sdkErrors.ErrStateInitializationFailed.Wrap(err)
failErr.Msg = "failed to get SPIFFE Workload API source"
log.FatalErr(appName, *failErr)
}
defer func() {
err := spiffe.CloseSource(source)
if err != nil {
log.WarnErr(appName, *err)
}
}()
log.Info(
appName,
"message", "acquired source",
"spiffe_id", selfSPIFFEID,
)
// I should be SPIKE Nexus.
if !spiffeid.IsNexus(selfSPIFFEID) {
failErr := *sdkErrors.ErrStateInitializationFailed.Clone()
failErr.Msg = "SPIFFE ID is not valid: " + selfSPIFFEID
log.FatalErr(appName, failErr)
}
initialization.Initialize(source)
log.Info(
appName,
"message", "started service",
"version", config.NexusVersion,
)
// Serve the app.
sdkNet.ServeWithRoute(
appName,
source,
http.Route,
// AllowAll, because any workload can talk to SPIKE Nexus if they
// 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,
// if the matcher is not provided, AllowAll will be assumed).
predicate.AllowAll,
env.NexusTLSPortVal(),
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package initialization
import (
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/initialization/recovery"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// Initialize initializes the SPIKE Nexus backing store based on the configured
// backend store type. The function handles three initialization modes:
//
// 1. SPIKE-Keeper-based initialization (SQLite and Lite backends):
// - Initializes the backing store from SPIKE Keeper instances
// - Starts a background goroutine for periodic shard synchronization
//
// 2. In-memory initialization (Memory backend):
// - Initializes an empty in-memory backing store without the root key
// - Logs warnings about non-production use
// - Does not use SPIKE Keepers for disaster recovery.
//
// 3. Invalid backend type:
// - Terminates the program with a fatal error
//
// Parameters:
// - source: An X509Source that provides X.509 certificates and private keys
// for SPIFFE-based mTLS authentication when communicating with SPIKE
// Keepers. Can be nil. Only used for SQLite and Lite backend types.
// For memory backend, this parameter is ignored. For SQLite/Lite backends,
// if the source is nil, the recovery functions will log warnings and retry
// until a valid source becomes available.
//
// Backend type configuration is determined by env.BackendStoreType().
// Valid backend types are: 'sqlite', 'lite', or 'memory'.
//
// The function will call log.FatalLn and terminate the program if an invalid
// backend store type is configured.
func Initialize(source *workloadapi.X509Source) {
const fName = "Initialize"
requireBackingStoreToBootstrap := env.BackendStoreTypeVal() == env.Sqlite ||
env.BackendStoreTypeVal() == env.Lite
if 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),
// SPIKE Nexus should be initialized internally.
recovery.InitializeBackingStoreFromKeepers(source)
// Lazy evaluation in a loop:
// If bootstrapping is successful, start a background process to
// periodically sync shards.
go recovery.SendShardsPeriodically(source)
return
}
devMode := env.BackendStoreTypeVal() == env.Memory
if devMode {
log.Warn(
fName,
"message", "in-memory mode: no SPIKE Keepers, not for production",
)
// `nil` will skip root key initialization and simply initializes an
// in-memory backing store.
state.Initialize(nil)
return
}
// Unknown store type.
// Better to crash, since this is likely a configuration failure.
log.FatalLn(
fName,
"message",
"invalid backend store type",
"type", env.BackendStoreTypeVal(),
"valid_types", "sqlite, lite, memory",
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package recovery
import (
"strconv"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// iterateKeepersAndInitializeState retrieves Shamir secret shards from multiple
// SPIKE Keeper instances and attempts to reconstruct the root key when a
// threshold number of shards is collected.
//
// The function iterates through all configured keepers, requesting their shards
// via SPIFFE mTLS. Once the Shamir threshold is reached, it reconstructs the
// root key and initializes the system state. This function implements secure
// memory handling, ensuring sensitive data is cleared after use.
//
// Parameters:
// - source: An X.509 source for mTLS authentication when communicating with
// keeper services
// - successfulKeeperShards: A map storing successfully retrieved shards
// indexed by keeper ID. Each shard is a fixed-size byte array of size
// 32.
//
// Returns:
// - bool: true if the system was successfully initialized with the
// reconstructed root key, false if initialization failed or insufficient
// shards were collected
//
// Security considerations:
// - All sensitive data (shards, root key) is securely erased from memory
// after use
// - The function will fatal log and terminate if keeper IDs cannot be
// converted to integers
// - Shards are validated to ensure they are not zeroed before being accepted
//
// The function performs the following steps:
// 1. Iterates through all configured keepers from env.Keepers()
// 2. For each keeper, requests its shard via HTTP using mTLS authentication
// 3. Validates and stores successful shard responses
// 4. When the threshold is reached, reconstructs the root key using Shamir's
// Secret Sharing
// 5. Initializes the system state with the recovered root key
// 6. Securely clears all sensitive data from memory
func iterateKeepersAndInitializeState(
source *workloadapi.X509Source,
successfulKeeperShards map[string]*[crypto.AES256KeySize]byte,
) bool {
const fName = "iterateKeepersAndInitializeState"
// In memory mode, no recovery is needed regardless of source availability
if env.BackendStoreTypeVal() == env.Memory {
log.Warn(fName, "message", "in memory mode: skipping recovery")
return true
}
// 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
// 2. Workload API may temporarily lose source and recover
// 3. Returning false allows the system to retry and recover gracefully
if source == nil {
failErr := sdkErrors.ErrSPIFFENilX509Source.Clone()
failErr.Msg = "X509 source is nil, cannot perform mTLS with keepers"
log.WarnErr(fName, *failErr)
return false
}
for keeperID, keeperAPIRoot := range env.KeepersVal() {
log.Info(
fName,
"message", "iterating keepers",
"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
}
data, err := shardGetResponse(source, u)
if err != nil {
warnErr := sdkErrors.ErrNetPeerConnection.Wrap(err)
warnErr.Msg = "failed to get shard from keeper"
log.WarnErr(fName, *warnErr) // just log: will retry
continue
}
res, unmarshalErr := unmarshalShardResponse(data)
// Security: Reset data before the function exits.
mem.ClearBytes(data)
if unmarshalErr != nil {
failErr := unmarshalErr.Clone()
failErr.Msg = "failed to unmarshal shard response"
log.WarnErr(fName, *failErr) // just log: will retry
continue
}
if mem.Zeroed32(res.Shard) {
warnErr := *sdkErrors.ErrShamirEmptyShard.Clone()
warnErr.Msg = "shard is zeroed"
log.WarnErr(fName, warnErr) // just log: will retry
continue
}
successfulKeeperShards[keeperID] = res.Shard
if len(successfulKeeperShards) != env.ShamirThresholdVal() {
log.Info(
fName,
"message", "still shards remaining",
"id", keeperID,
"url", keeperAPIRoot,
"has", len(successfulKeeperShards),
"needs", env.ShamirThresholdVal(),
)
continue
}
log.Info(fName, "message", "threshold reached")
// No need to erase `ss` because upon successful recovery,
// `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)
for ix, shard := range successfulKeeperShards {
id, err := strconv.Atoi(ix)
if err != nil {
// Unlike URL misconfiguration (which we tolerate above), an
// unparseable keeper ID is fatal because:
// 1. We've already collected threshold shards. Skipping one now
// means we'd need to re-fetch, but the same ID will fail
// again.
// 2. The keeper ID is used as the Shamir shard index. Using a
// wrong index produces an incorrect root key, which is worse
// than crashing.
// 3. This same ID was used during bootstrap to store the shard.
// If it was valid then but invalid now, the configuration
// has been corrupted.
failErr := sdkErrors.ErrDataInvalidInput.Wrap(err)
failErr.Msg = "failed to convert keeper ID to int"
log.FatalErr(fName, *failErr)
return false
}
ss = append(ss, ShamirShard{
ID: uint64(id),
Value: shard,
})
}
rk := ComputeRootKeyFromShards(ss)
// Security: Crash if there is a problem with root key recovery.
if rk == nil || mem.Zeroed32(rk) {
failErr := *sdkErrors.ErrShamirReconstructionFailed.Clone()
failErr.Msg = "failed to recover the root key"
log.FatalErr(fName, failErr)
}
// It is okay to zero out `rk` after calling this function because we
// make a copy of rk.
state.Initialize(rk)
// Security: Zero out temporary variables before the function exits.
mem.ClearRawBytes(rk)
// Security: Zero out temporary variables before the function exits.
// Note that `successfulKeeperShards` will be reset elsewhere.
mem.ClearRawBytes(res.Shard)
// System initialized: Exit loop.
return true
}
// Failed to initialize.
return false
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package recovery
import (
"context"
"fmt"
"math/big"
"time"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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/retry"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/spiffe"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// InitializeBackingStoreFromKeepers iterates through keepers until
// you get two shards.
//
// Any 400 and 5xx response that a SPIKE Keeper gives is likely temporary.
// We should keep trying until we get a 200 or 404 response.
//
// This function attempts to recover the backing store by collecting shards
// from keeper nodes. It continuously polls the keepers until enough valid
// shards are collected to reconstruct the backing store. The function blocks
// until recovery is successful.
//
// The function maintains a map of successfully recovered shards from each
// keeper to avoid duplicate processing. On failure, it retries with an
// exponential backoff with a max retry delay of 5 seconds.
// The retry timeout is loaded from `env.RecoveryOperationTimeout` and
// defaults to 0 (unlimited; no timeout).
//
// Parameters:
// - source: An X509Source used for SPIFFE-based mTLS authentication with
// SPIKE Keeper nodes. Can be nil. If `source` is nil during a retry
// iteration, the function will log a warning and retry. This graceful
// handling allows recovery from transient workload API failures where
// the source may be temporarily unavailable but can be restored in
// the following retry attempts.
func InitializeBackingStoreFromKeepers(source *workloadapi.X509Source) {
const fName = "InitializeBackingStoreFromKeepers"
log.Info(fName, "message", "recovering backing store using keeper shards")
successfulKeeperShards := make(map[string]*[crypto.AES256KeySize]byte)
// Security: Ensure the shards are zeroed out after use.
defer func() {
for id := range successfulKeeperShards {
// Note: We cannot simply use `mem.ClearRawBytes(successfulKeeperShards)`
// because it will reset the pointer but not the data it points to.
mem.ClearRawBytes(successfulKeeperShards[id])
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Note: It is okay for SPIKE Nexus to continue asking shards from
// SPIKE Keepers in an infinite loop: The operator may optionally
// configure SPIKE Keepers to be hydrated out-of-band instead of
// automatically bootstrapping them during SPIKE installation; or
// they might decide to "reset" the system with new shards. We
// cannot assume that SPIKE Nexus initialization always completes
// in a timely manner. If things take longer than usual, there are
// always logs that can be inspected to root-cause the issue.
_, err := retry.Forever(ctx, func() (bool, *sdkErrors.SDKError) {
log.Debug(fName, "message", "retry attempt", "time", time.Now().String())
// Early check: avoid unnecessary function call if the source is nil
if source == nil {
warnErr := *sdkErrors.ErrSPIFFENilX509Source.Clone()
warnErr.Msg = "X509 source is nil, will retry"
log.WarnErr(fName, warnErr)
return false, sdkErrors.ErrRecoveryRetryFailed
}
initSuccessful := iterateKeepersAndInitializeState(
source, successfulKeeperShards,
)
if initSuccessful {
log.Info(fName, "message", "initialization successful")
return true, nil
}
warnErr := *sdkErrors.ErrRecoveryRetryFailed.Clone()
warnErr.Msg = "initialization unsuccessful: will retry"
log.WarnErr(fName, warnErr)
return false, sdkErrors.ErrRecoveryRetryFailed
})
// This should never happen since the above loop retries forever:
if err != nil {
failErr := sdkErrors.ErrRecoveryFailed.Wrap(err)
failErr.Msg = "initialization failed"
log.FatalErr(fName, *failErr)
}
}
// RestoreBackingStoreFromPilotShards restores the backing store using the
// provided Shamir secret sharing shards. It requires at least the threshold
// number of shards (as configured in the environment) to successfully
// recover the root key. Once the root key is recovered, it initializes the
// state and sends the shards to the keepers.
//
// Parameters:
// - shards []*[32]byte: A slice of byte array pointers representing the
// shards
//
// The function will:
// - Validate that enough shards are provided (at least the threshold amount)
// - Recover the root key using the Shamir secret sharing algorithm
// - Initialize the state with the recovered key
// - Send the shards to the configured keepers
//
// 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) {
const fName = "RestoreBackingStoreFromPilotShards"
log.Info(
fName,
"message", "restoring backing store using pilot shards",
)
// Sanity check:
for shard := range shards {
value := shards[shard].Value
id := shards[shard].ID
// Security: Crash immediately if data is corrupt.
if value == nil || mem.Zeroed32(value) || id == 0 {
failErr := *sdkErrors.ErrShamirNilShard.Clone()
failErr.Msg = "bad input: ID or Value of a shard is zero"
log.FatalErr(fName, failErr)
return
}
}
// Ensure we have at least the threshold number of shards
if len(shards) < env.ShamirThresholdVal() {
failErr := *sdkErrors.ErrShamirNotEnoughShards.Clone()
failErr.Msg = "insufficient shards for recovery"
log.FatalErr(fName, failErr)
return
}
log.Debug(
fName,
"message", "shard validation passed",
"threshold", env.ShamirThresholdVal(),
"provided", len(shards),
)
// Recover the root key using the threshold number of shards
rk := ComputeRootKeyFromShards(shards)
if rk == nil || mem.Zeroed32(rk) {
failErr := *sdkErrors.ErrShamirReconstructionFailed.Clone()
failErr.Msg = "failed to recover the root key"
log.FatalErr(fName, failErr)
}
// Security: Ensure the root key is zeroed out after use.
defer func() {
mem.ClearRawBytes(rk)
}()
log.Info(fName, "message", "initializing state and root key")
state.Initialize(rk)
source, _, err := spiffe.Source(
context.Background(), spiffe.EndpointSocket(),
)
if err != nil {
failErr := sdkErrors.ErrSPIFFEUnableToFetchX509Source.Wrap(err)
failErr.Msg = "failed to create SPIFFE source"
log.FatalErr(fName, *failErr)
return
}
defer func() {
closeErr := spiffe.CloseSource(source)
if closeErr != nil {
log.WarnErr(fName, *closeErr)
}
}()
// Don't wait for the next cycle in `SendShardsPeriodically`.
// Send the shards asap.
sendShardsToKeepers(source, env.KeepersVal())
}
// SendShardsPeriodically distributes key shards to configured keeper nodes at
// regular intervals. It creates new shards from the current root key and sends
// them to each keeper using mTLS authentication. The function runs indefinitely
// until stopped.
//
// The function sends shards every 5 minutes. It requires a minimum number of
// keepers equal to the configured Shamir shares. If any operation fails for a
// keeper (URL creation, mTLS setup, marshaling, or network request), it logs a
// warning and continues with the next keeper.
//
// Parameters:
// - source: An X509Source used for creating SPIFFE-based mTLS connections to
// keepers. Can be nil. If `source` is nil during any iteration, the
// function performs an early check and skips shard distribution for that
// iteration, logging a warning and waiting for the next scheduled interval.
// This graceful handling allows recovery from transient workload API
// failures.
func SendShardsPeriodically(source *workloadapi.X509Source) {
const fName = "SendShardsPeriodically"
log.Info(fName, "message", "will send shards to keepers")
ticker := time.NewTicker(env.RecoveryKeeperUpdateIntervalVal())
defer ticker.Stop()
for range ticker.C {
log.Debug(fName, "message", "sending shards to keepers")
// Early check: skip if `source` is nil
if source == nil {
warnErr := *sdkErrors.ErrSPIFFENilX509Source.Clone()
warnErr.Msg = "X509 source is nil: skipping shard send"
log.WarnErr(fName, warnErr)
continue
}
// If no root key, then skip.
if state.RootKeyZero() {
log.Warn(fName, "message", "no root key: skipping shard send")
continue
}
// Ensures the number of keepers matches the Shamir shares required.
keepers := env.KeepersVal()
expectedShares := env.ShamirSharesVal()
if len(keepers) != expectedShares {
failErr := sdkErrors.ErrShamirNotEnoughKeepers.Clone()
failErr.Msg = fmt.Sprintf(
"keeper count mismatch: SPIKE_NEXUS_SHAMIR_SHARES=%d "+
"but %d keepers configured in SPIKE_NEXUS_KEEPER_PEERS; "+
"these values must match",
expectedShares, len(keepers),
)
log.FatalErr(fName, *failErr)
return
}
sendShardsToKeepers(source, keepers)
}
}
// NewPilotRecoveryShards generates a set of recovery shards from the root key
// using Shamir's Secret Sharing scheme. These shards can be used to reconstruct
// the root key in a recovery scenario.
//
// The function first retrieves the root key from the system state. If no root
// key exists, it returns nil. Otherwise, it splits the root key into shards
// using a secret sharing scheme, performs validation checks, and converts the
// shares into byte arrays.
//
// Each shard in the returned map (keyed by shard ID) represents a portion of
// the secret needed to reconstruct the root key. The shares are generated in a
// way that requires a specific threshold of shards to be combined to recover
// the original secret.
//
// Security and Error Handling:
//
// This function uses a fail-fast strategy with log.FatalErr for any errors
// during shard generation. This is intentional and critical for security:
// - Shard generation failures indicate memory corruption, crypto library
// bugs, or corrupted internal state
// - Continuing to operate with corrupted shards could propagate invalid
// recovery data to operators
// - An operator storing broken shards would discover they are useless only
// during an actual recovery scenario
// - Crashing immediately ensures the system fails securely rather than
// silently generating invalid recovery material
//
// Returns:
// - map[int]*[32]byte: A map of shard IDs to byte array pointers representing
// the recovery shards. Returns nil if the root key is not available.
//
// Example:
//
// shards := NewPilotRecoveryShards()
// for id, shard := range shards {
// // Store each shard securely
// storeShard(id, shard)
// }
func NewPilotRecoveryShards() map[int]*[crypto.AES256KeySize]byte {
const fName = "NewPilotRecoveryShards"
log.Info(fName, "message", "generating pilot recovery shards")
if state.RootKeyZero() {
log.Warn(fName, "message", "no root key: skipping generation")
return nil
}
state.LockRootKey()
defer state.UnlockRootKey()
rk := state.RootKeyNoLock()
rootSecret, rootShards := crypto.ComputeShares(rk)
// Security: Ensure the root key and shards are zeroed out after use.
defer func() {
rootSecret.SetUint64(0)
for i := range rootShards {
rootShards[i].Value.SetUint64(0)
}
}()
var result = make(map[int]*[crypto.AES256KeySize]byte)
for _, shard := range rootShards {
log.Debug(fName, "message", "processing shard", "shard_id", shard.ID)
contribution, marshalErr := shard.Value.MarshalBinary()
if marshalErr != nil {
failErr := sdkErrors.ErrDataMarshalFailure.Wrap(marshalErr)
failErr.Msg = "failed to marshal shard"
log.FatalErr(fName, *failErr)
return nil
}
if len(contribution) != crypto.AES256KeySize {
failErr := *sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "length of shard is unexpected"
log.FatalErr(fName, failErr)
return nil
}
bb, idMarshalErr := shard.ID.MarshalBinary()
if idMarshalErr != nil {
failErr := sdkErrors.ErrDataMarshalFailure.Wrap(idMarshalErr)
failErr.Msg = "failed to marshal shard ID"
log.FatalErr(fName, *failErr)
return nil
}
bigInt := new(big.Int).SetBytes(bb)
ii := bigInt.Uint64()
var rs [crypto.AES256KeySize]byte
copy(rs[:], contribution)
result[int(ii)] = &rs
}
log.Info(fName,
"message", "generated pilot recovery shards",
"count", len(result))
return result
}
// \\ 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{}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package recovery
import (
"encoding/json"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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"
)
// shardGetResponse retrieves a shard from a SPIKE Keeper via mTLS POST request.
// It creates an mTLS client using the provided X509 source with a predicate
// that only allows communication with SPIKE Keeper instances.
//
// Parameters:
// - source: X509Source for mTLS authentication with the keeper
// - u: The URL of the keeper's shard retrieval endpoint
//
// Returns:
// - []byte: The raw shard response data from the keeper
// - *sdkErrors.SDKError: An error if the request fails, nil on success
//
// The function will return an error if:
// - The X509 source is nil
// - The request marshaling fails
// - The POST request fails
// - The response is empty
func shardGetResponse(
source *workloadapi.X509Source, u string,
) ([]byte, *sdkErrors.SDKError) {
if source == nil {
failErr := sdkErrors.ErrSPIFFENilX509Source.Clone()
failErr.Msg = "X509 source is nil"
return nil, failErr
}
shardRequest := reqres.ShardGetRequest{}
md, err := json.Marshal(shardRequest)
if err != nil {
failErr := sdkErrors.ErrDataMarshalFailure.Wrap(err)
failErr.Msg = "failed to marshal shard request"
return nil, failErr
}
client := net.CreateMTLSClientWithPredicate(
source,
// Security: Only get shards from SPIKE Keepers.
predicate.AllowKeeper,
)
data, postErr := net.Post(client, u, md)
if postErr != nil {
return nil, postErr
}
if len(data) == 0 {
failErr := *sdkErrors.ErrAPIEmptyPayload.Clone()
failErr.Msg = "received empty shard data from keeper"
return nil, &failErr
}
return data, nil
}
// \\ 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"
// Helper function for URL path checking
func containsPathUpdate(fullURL, path string) bool {
parsedURL, err := url.Parse(fullURL)
if err != nil {
return false
}
// Clean the path from leading/trailing slashes for comparison
cleanPath := path
if len(cleanPath) > 0 && cleanPath[0] == '/' {
cleanPath = cleanPath[1:]
}
return len(parsedURL.Path) > 0 &&
(parsedURL.Path[len(parsedURL.Path)-len(cleanPath):] == cleanPath ||
parsedURL.Path == "/"+cleanPath)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package recovery
import (
"encoding/json"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// unmarshalShardResponse deserializes JSON data into a ShardGetResponse
// structure.
//
// This function is used during the recovery process to parse HTTP responses
// from SPIKE Keeper instances when retrieving Shamir secret shards.
//
// Parameters:
// - data: The raw JSON response body from a keeper shard endpoint
//
// Returns:
// - *reqres.ShardGetResponse: A pointer to the deserialized response
// containing the shard data, or nil if unmarshaling fails
// - *sdkErrors.SDKError: An error if JSON unmarshaling fails, nil on success
func unmarshalShardResponse(data []byte) (
*reqres.ShardGetResponse, *sdkErrors.SDKError,
) {
var res reqres.ShardGetResponse
err := json.Unmarshal(data, &res)
if err != nil {
failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(err)
failErr.Msg = "failed to unmarshal response"
return nil, failErr
}
return &res, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package recovery
import (
"encoding/json"
"net/url"
"strconv"
"github.com/cloudflare/circl/group"
"github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiUrl "github.com/spiffe/spike-sdk-go/api/url"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/predicate"
"github.com/spiffe/spike-sdk-go/security/mem"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// sendShardsToKeepers distributes shares of the root key to all keeper nodes.
// Shares are recomputed for each keeper rather than computed once and
// distributed. This is safe because:
// 1. computeShares() uses a deterministic random reader seeded with the
// root key
// 2. Given the same root key, it will always produce identical shares
// 3. Each keeper receives its designated share based on keeper ID
//
// This approach simplifies the code flow and maintains consistency across
// potential system restarts or failures.
//
// The function optimistically moves on to the next SPIKE Keeper in the list on
// error. This is acceptable because SPIKE Nexus does not need all keepers to be
// healthy simultaneously. Since shards are sent periodically, all SPIKE Keepers
// will eventually receive their shards provided there is no configuration
// error.
//
// Parameters:
// - source: X509Source for mTLS authentication with keepers
// - keepers: Map of keeper IDs to their API root URLs
func sendShardsToKeepers(
source *workloadapi.X509Source, keepers map[string]string,
) {
const fName = "sendShardsToKeepers"
for keeperID, keeperAPIRoot := range keepers {
u, urlErr := url.JoinPath(
keeperAPIRoot, string(apiUrl.KeeperContribute),
)
if urlErr != nil {
warnErr := sdkErrors.ErrAPIBadRequest.Wrap(urlErr)
warnErr.Msg = "failed to join path"
log.WarnErr(fName, *warnErr)
continue
}
if state.RootKeyZero() {
log.Warn(fName, "message", "rootKey is zero: moving on")
continue
}
state.LockRootKey()
rootSecret, rootShares := crypto.ComputeShares(state.RootKeyNoLock())
// not using `defer` because ComputeShare is deterministic, it does not
// return an error, and `root key` is not nil. -- using defer in a loop
// can potentially leak resources. An alternative approach could be to
// use a closure or create a copy of the root key; both of the approaches
// complicate the code further.
state.UnlockRootKey()
var share secretsharing.Share
for _, sr := range rootShares {
kid, atoiErr := strconv.Atoi(keeperID)
if atoiErr != nil {
warnErr := sdkErrors.ErrDataInvalidInput.Wrap(atoiErr)
warnErr.Msg = "failed to convert keeper id to int"
log.WarnErr(fName, *warnErr)
continue
}
if sr.ID.IsEqual(group.P256.NewScalar().SetUint64(uint64(kid))) {
share = sr
break
}
}
if share.ID.IsZero() {
warnErr := *sdkErrors.ErrEntityNotFound.Clone()
warnErr.Msg = "failed to find share for keeper"
log.WarnErr(fName, warnErr)
continue
}
rootSecret.SetUint64(0)
contribution, marshalErr := share.Value.MarshalBinary()
if marshalErr != nil {
warnErr := sdkErrors.ErrDataMarshalFailure.Wrap(marshalErr)
warnErr.Msg = "failed to marshal share"
log.WarnErr(fName, *warnErr)
// Security: Ensure sensitive data is zeroed out.
mem.ClearBytes(contribution)
share.Value.SetUint64(0)
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
continue
}
if len(contribution) != crypto.AES256KeySize {
// Log before clearing (contribution length is needed for logging).
warnErr := *sdkErrors.ErrDataInvalidInput.Clone()
warnErr.Msg = "invalid contribution length"
log.WarnErr(fName, warnErr)
// Security: Ensure sensitive data is zeroed out.
// Note: use mem.ClearBytes() for slices, not mem.ClearRawBytes().
mem.ClearBytes(contribution)
share.Value.SetUint64(0)
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
continue
}
scr := reqres.ShardPutRequest{}
shard := new([crypto.AES256KeySize]byte)
// Security: shard is intentionally binary (instead of string) for
// better memory management. Do not change its data type.
copy(shard[:], contribution)
scr.Shard = shard
md, jsonErr := json.Marshal(scr)
// Security: Erase sensitive data when no longer in use.
mem.ClearRawBytes(scr.Shard)
mem.ClearBytes(contribution)
share.Value.SetUint64(0)
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
if jsonErr != nil {
warnErr := sdkErrors.ErrDataMarshalFailure.Wrap(jsonErr)
warnErr.Msg = "failed to marshal request"
log.WarnErr(fName, *warnErr)
continue
}
// Security: Only SPIKE Keeper can send shards to SPIKE Nexus.
// Create the client just before use to avoid unnecessary allocation
// if earlier checks fail.
client := net.CreateMTLSClientWithPredicate(
source, predicate.AllowKeeper,
)
_, postErr := net.Post(client, u, md)
// Security: Ensure that md is zeroed out.
mem.ClearBytes(md)
if postErr != nil {
warnErr := sdkErrors.ErrAPIPostFailed.Wrap(postErr)
warnErr.Msg = "failed to post shard to keeper"
log.WarnErr(fName, *warnErr)
continue
}
}
}
// \\ 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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
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"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteDeletePolicy handles HTTP DELETE requests to remove existing policies.
// It processes the request body to delete a policy specified by its ID.
//
// The function expects a JSON request body containing:
// - ID: unique identifier of the policy to delete
//
// On success, it returns an empty JSON response with HTTP 200 status.
// On failure, it returns an appropriate error response with status code.
//
// Parameters:
// - w: HTTP response writer for sending the response
// - r: HTTP request containing the policy ID to delete
// - audit: Audit entry for logging the policy deletion action
//
// Returns:
// - *sdkErrors.SDKError: nil on successful policy deletion, error otherwise
//
// Example request body:
//
// {
// "id": "policy-123"
// }
//
// Example success response:
//
// {}
//
// Example not found response:
//
// {
// "err": "not_found"
// }
//
// Example error response:
//
// {
// "err": "Internal server error"
// }
//
// HTTP Status Codes:
// - 200: Policy deleted successfully
// - 404: Policy not found
// - 500: Internal server error
//
// Possible errors:
// - Failed to read request body
// - Failed to parse request body
// - Policy not found
// - Internal server error during policy deletion
func RouteDeletePolicy(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
const fName = "RouteDeletePolicy"
journal.AuditRequest(fName, r, audit, journal.AuditDelete)
request, err := net.ReadParseAndGuard[
reqres.PolicyDeleteRequest, reqres.PolicyDeleteResponse,
](
w, r, reqres.PolicyDeleteResponse{}.BadRequest(), guardPolicyDeleteRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
policyID := request.ID
deleteErr := state.DeletePolicy(policyID)
if deleteErr != nil {
return net.RespondWithHTTPError(deleteErr, w, reqres.PolicyDeleteResponse{})
}
return net.Success(reqres.PolicyDeleteResponse{}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
cfg "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardPolicyDeleteRequest validates a policy deletion request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the policy ID format
// - Checks if the peer has write permission for the policy access path
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The policy deletion request containing the policy ID
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: nil if all validations pass,
// ErrAccessUnauthorized if authentication or authorization fails,
// ErrDataInvalidInput if policy ID validation fails
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
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), cfg.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
failErr := net.Fail(
reqres.PolicyDeleteResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
if failErr != nil {
return sdkErrors.ErrAccessUnauthorized.Wrap(failErr)
}
return sdkErrors.ErrAccessUnauthorized.Clone()
}
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
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"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteGetPolicy handles HTTP requests to retrieve a specific policy by its ID.
// It processes the request body to fetch detailed information about a single
// policy.
//
// The function expects a JSON request body containing:
// - ID: unique identifier of the policy to retrieve
//
// On success, it returns the complete policy object. If the policy is not
// found, it returns a "not found" error. For other errors, it returns an
// internal server error.
//
// Parameters:
// - w: HTTP response writer for sending the response
// - r: HTTP request containing the policy ID to retrieve
// - audit: Audit entry for logging the policy read action
//
// Returns:
// - *sdkErrors.SDKError: nil on successful retrieval, ErrEntityNotFound if
// policy not found, other errors on system failures
//
// Example request body:
//
// {
// "id": "policy-123"
// }
//
// Example success response:
//
// {
// "policy": {
// "id": "policy-123",
// "name": "example-policy",
// "spiffe_id_pattern": "^spiffe://example\.org/.*/service",
// "path_pattern": "^api/",
// "permissions": ["read", "write"],
// "created_at": "2024-01-01T00:00:00Z",
// "created_by": "user-abc"
// }
// }
//
// Example not found response:
//
// {
// "err": "not_found"
// }
//
// Example error response:
//
// {
// "err": "Internal server error"
// }
//
// HTTP Status Codes:
// - 200: Policy found and returned successfully
// - 404: Policy not found
// - 500: Internal server error
//
// Possible errors:
// - Failed to read request body
// - Failed to parse request body
// - Failed to marshal response body
// - Policy not found
// - Internal server error during policy retrieval
func RouteGetPolicy(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
const fName = "RouteGetPolicy"
journal.AuditRequest(fName, r, audit, journal.AuditRead)
request, err := net.ReadParseAndGuard[
reqres.PolicyReadRequest, reqres.PolicyReadResponse](
w, r, reqres.PolicyReadResponse{}.BadRequest(), guardPolicyReadRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
policyID := request.ID
policy, policyErr := state.GetPolicy(policyID)
if policyErr != nil {
return net.RespondWithHTTPError(policyErr, w, reqres.PolicyReadResponse{})
}
return net.Success(reqres.PolicyReadResponse{Policy: policy}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardPolicyReadRequest validates a policy read request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the policy ID format
// - Checks if the peer has read permission for the policy access path
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The policy read request containing the policy ID
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: nil if all validations pass,
// ErrAccessUnauthorized if authentication or authorization fails,
// ErrDataInvalidInput if policy ID validation fails
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
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), apiAuth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionRead},
)
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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
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/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteListPolicies handles HTTP requests to retrieve policies.
// It can list all policies or filter them by a SPIFFE ID pattern or a path
// pattern. The function returns a list of policies matching the criteria.
//
// The request body can be empty to list all policies, or it can contain
// `spiffe_id_pattern` or `path_pattern` for filtering. These two filter
// parameters cannot be used together.
//
// Parameters:
// - w: HTTP response writer for sending the response
// - r: HTTP request for the policy listing operation
// - audit: Audit entry for logging the policy list action
//
// Returns:
// - *sdkErrors.SDKError: nil on successful retrieval, error otherwise
//
// Example request body (list all):
//
// {}
//
// Example request body (filter by SPIFFE ID):
//
// {
// "spiffe_id_pattern": "^spiffe://example\\.org/app$"
// }
//
// Example request body (filter by path):
//
// {
// "path_pattern": "^secrets/db/.*$"
// }
//
// Possible errors:
// - Failed to read request body
// - Failed to parse request body
// - `spiffe_id_pattern` and `path_pattern` used together (validated by the
// request guard)
// - Failed to marshal response body
func RouteListPolicies(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
fName := "RouteListPolicies"
journal.AuditRequest(fName, r, audit, journal.AuditList)
request, err := net.ReadParseAndGuard[
reqres.PolicyListRequest, reqres.PolicyListResponse](
w, r, reqres.PolicyListResponse{}.BadRequest(), guardListPolicyRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
var policies []data.Policy
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
var listErr *sdkErrors.SDKError
// Note that Go's default switch behavior will not fall through.
switch {
case SPIFFEIDPattern != "":
policies, listErr = state.ListPoliciesBySPIFFEIDPattern(SPIFFEIDPattern)
case pathPattern != "":
policies, listErr = state.ListPoliciesByPathPattern(pathPattern)
default:
policies, listErr = state.ListPolicies()
}
if listErr != nil {
return net.RespondWithHTTPError(listErr, w, reqres.PolicyListResponse{})
}
items := make([]data.PolicyListItem, len(policies))
for i, p := range policies {
items[i] = data.PolicyListItem{
ID: p.ID,
Name: p.Name,
}
}
return net.Success(reqres.PolicyListResponse{Policies: items}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
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"
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.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Checks if the peer has list permission for the policy access path
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The policy list request (currently unused, reserved for future
// validation needs)
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - nil if all validations pass
// - apiErr.ErrUnauthorized if authentication or authorization fails
func guardListPolicyRequest(
_ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return net.RespondUnauthorizedOnPredicateFail(hasListPermission,
reqres.PolicyListResponse{}.Unauthorized(), w, r)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
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/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RoutePutPolicy handles HTTP requests for creating or updating policies.
// It processes the request body to upsert a policy with the specified name,
// SPIFFE ID pattern, path pattern, and permissions.
//
// This handler follows upsert semantics consistent with secret operations:
// - If no policy with the given name exists, a new policy is created
// - If a policy with the same name exists, it is updated
//
// The function expects a JSON request body containing:
// - Name: policy name (used as the unique identifier for upsert)
// - SPIFFEIDPattern: SPIFFE ID matching pattern (regex)
// - PathPattern: path matching pattern (regex)
// - Permissions: set of allowed permissions
//
// On success, it returns a JSON response with the policy's ID.
// On failure, it returns an appropriate error response with status code.
//
// Parameters:
// - w: HTTP response writer for sending the response
// - r: HTTP request containing the policy data
// - audit: Audit entry for logging the policy upsert action
//
// Returns:
// - *sdkErrors.SDKError: nil on successful policy upsert, error otherwise
//
// Example request body:
//
// {
// "name": "example-policy",
// "spiffe_id_pattern": "^spiffe://example\\.org/.*/service$",
// "path_pattern": "^secrets/db/.*$",
// "permissions": ["read", "write"]
// }
//
// Example success response:
//
// {
// "id": "policy-123"
// }
//
// Example error response:
//
// {
// "err": "Internal server error"
// }
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,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
policy, upsertErr := state.UpsertPolicy(data.Policy{
Name: request.Name,
SPIFFEIDPattern: request.SPIFFEIDPattern,
PathPattern: request.PathPattern,
Permissions: request.Permissions,
})
if upsertErr != nil {
return net.RespondWithHTTPError(upsertErr, w, reqres.PolicyPutResponse{})
}
return net.Success(reqres.PolicyPutResponse{ID: policy.ID}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
cfg "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
func hasWritePermission(peerSPIFFEID string) bool {
return state.CheckAccess(
peerSPIFFEID, cfg.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
}
// guardPolicyCreateRequest validates a policy creation request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Checks if the peer has write permission for the policy access path
// - Validates the policy name format
// - Validates the SPIFFE ID pattern (regex)
// - Validates the path pattern (regex)
// - Validates the permissions list
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The policy creation request containing policy details
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - nil if all validations pass
// - apiErr.ErrUnauthorized if authentication or authorization fails
// - apiErr.ErrInvalidInput if any input validation fails
func guardPolicyCreateRequest(
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
}
name := request.Name
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
permissions := request.Permissions
if err := validation.ValidateName(name); err != nil {
failErr := net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
if failErr != nil {
return sdkErrors.ErrDataInvalidInput.Wrap(failErr)
}
return sdkErrors.ErrDataInvalidInput.Clone()
}
if err := validation.ValidateSPIFFEIDPattern(SPIFFEIDPattern); err != nil {
failEr := net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
if failEr != nil {
return sdkErrors.ErrDataInvalidInput.Wrap(failEr)
}
return sdkErrors.ErrDataInvalidInput.Clone()
}
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 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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"github.com/spiffe/spike-sdk-go/api/url"
"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
// handlers when the backing store is initialized and available.
//
// This function routes requests to handlers that require an initialized
// backing store, including secret operations, policy management, metadata
// queries, operator functions, cipher operations, and bootstrap verification.
//
// Parameters:
// - a: The API action to perform (e.g., Get, Delete, List)
// - p: The API URL path identifier
//
// Returns:
// - net.Handler: The appropriate handler for the given action and URL,
// or net.Fallback if no matching route is found
func routeWithBackingStore(a url.APIAction, p url.APIURL) net.Handler {
switch {
case a == url.ActionDefault && p == url.NexusSecrets:
return secret.RoutePutSecret
case a == url.ActionGet && p == url.NexusSecrets:
return secret.RouteGetSecret
case a == url.ActionDelete && p == url.NexusSecrets:
return secret.RouteDeleteSecret
case a == url.ActionUndelete && p == url.NexusSecrets:
return secret.RouteUndeleteSecret
case a == url.ActionList && p == url.NexusSecrets:
return secret.RouteListPaths
case a == url.ActionDefault && p == url.NexusPolicy:
return policy.RoutePutPolicy
case a == url.ActionGet && p == url.NexusPolicy:
return policy.RouteGetPolicy
case a == url.ActionDelete && p == url.NexusPolicy:
return policy.RouteDeletePolicy
case a == url.ActionList && p == url.NexusPolicy:
return policy.RouteListPolicies
case a == url.ActionGet && p == url.NexusSecretsMetadata:
return secret.RouteGetSecretMetadata
case a == url.ActionDefault && p == url.NexusOperatorRestore:
return operator.RouteRestore
case a == url.ActionDefault && p == url.NexusOperatorRecover:
return operator.RouteRecover
case a == url.ActionDefault && p == url.NexusCipherEncrypt:
return cipher.RouteEncrypt
case a == url.ActionDefault && p == url.NexusCipherDecrypt:
return cipher.RouteDecrypt
case a == url.ActionDefault && p == url.NexusBootstrapVerify:
return bootstrap.RouteVerify
default:
return net.Fallback
}
}
// routeWithNoBackingStore maps API actions and URLs to their corresponding
// handlers when the backing store is not yet initialized.
//
// This function provides limited routing for operations that can function
// without an initialized backing store. Only operator recovery/restore and
// cipher operations are available in this mode. All other requests are
// routed to the fallback handler.
//
// Parameters:
// - a: The API action to perform
// - p: The API URL path identifier
//
// Returns:
// - net.Handler: The appropriate handler for the given action and URL,
// or net.Fallback if the operation requires a backing store
func routeWithNoBackingStore(a url.APIAction, p url.APIURL) net.Handler {
switch {
case a == url.ActionDefault && p == url.NexusBootstrapVerify:
return bootstrap.RouteVerify
case a == url.ActionDefault && p == url.NexusOperatorRecover:
return operator.RouteRecover
case a == url.ActionDefault && p == url.NexusOperatorRestore:
return operator.RouteRestore
case a == url.ActionDefault && p == url.NexusCipherEncrypt:
return cipher.RouteEncrypt
case a == url.ActionDefault && p == url.NexusCipherDecrypt:
return cipher.RouteDecrypt
default:
return net.Fallback
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package base contains the fundamental building blocks and core functions
// for handling HTTP requests in the SPIKE Nexus application. It provides
// the routing logic to map API actions and URL paths to their respective
// handlers while ensuring seamless request processing and response generation.
// This package serves as a central point for managing incoming API calls
// and delegating them to the correct functional units based on specified rules.
package base
import (
"net/http"
"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"
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
// executing the appropriate handler based on the request path and HTTP method.
// It uses a factory function to create the specific handler for the given URL
// path and HTTP method combination.
//
// Parameters:
// - w: The HTTP ResponseWriter to write the response to
// - r: The HTTP Request containing the client's request details
func Route(
w http.ResponseWriter, r *http.Request, a *journal.AuditEntry,
) *sdkErrors.SDKError {
return net.RouteFactory[url.APIAction](
url.APIURL(r.URL.Path),
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.
emptyRootKey := state.RootKeyZero()
inMemoryMode := env.BackendStoreTypeVal() == env.Memory
hasBackingStore := env.BackendStoreTypeVal() != env.Lite
emergencyAction := p == url.NexusOperatorRecover ||
p == url.NexusOperatorRestore
rootKeyValidationRequired := !inMemoryMode && !emergencyAction
if rootKeyValidationRequired && emptyRootKey {
return net.NotReady
}
if hasBackingStore {
return routeWithBackingStore(a, p)
}
// No backing store: We cannot store or retrieve secrets
// or policies directly.
// SPIKE is effectively a "crypto as a service" now.
return routeWithNoBackingStore(a, p)
})(w, r, a)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package bootstrap
import (
"crypto/sha256"
"encoding/hex"
"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-sdk-go/journal"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// RouteVerify handles HTTP requests from SPIKE Bootstrap to verify that
// SPIKE Nexus has been properly initialized and can decrypt data using the
// root key.
//
// This endpoint serves as a verification mechanism during the bootstrap
// process. Bootstrap encrypts a random text with the root key and sends it
// to Nexus. Nexus decrypts the text, computes its SHA-256 hash, and returns
// the hash to Bootstrap. Bootstrap can then verify the hash matches the
// original plaintext, confirming that Nexus has been properly initialized
// with the correct root key.
//
// The verification process:
// 1. Reads and validates the request containing nonce and ciphertext
// 2. Checks that the request comes from a Bootstrap SPIFFE ID
// 3. Retrieves the system cipher from the backend
// 4. Decrypts the ciphertext using the nonce
// 5. Computes SHA-256 hash of the decrypted plaintext
// 6. Returns the hash to Bootstrap for verification
//
// Access control is enforced through guardVerifyRequest to ensure only
// Bootstrap can call this endpoint.
//
// Parameters:
// - w http.ResponseWriter: The HTTP response writer
// - r *http.Request: The incoming HTTP request
// - audit *journal.AuditEntry: Audit entry for logging
//
// Returns:
// - error: An error if one occurs during processing, nil otherwise
//
// Errors:
// - Returns ErrReadFailure if request body cannot be read
// - Returns ErrParseFailure if JSON request cannot be parsed
// - Returns ErrInternal if cipher is unavailable or decryption fails
// - Returns ErrUnauthorized if request is not from Bootstrap
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[
reqres.BootstrapVerifyRequest, reqres.BootstrapVerifyResponse](
w, r, reqres.BootstrapVerifyResponse{}.BadRequest(), guardVerifyRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// Get cipher from the backend
c := persist.Backend().GetCipher()
if c == nil {
return net.RespondWithInternalError(
sdkErrors.ErrCryptoCipherNotAvailable, w,
reqres.BootstrapVerifyResponse{},
)
}
// Decrypt the ciphertext
plaintext, decryptErr := c.Open(nil, request.Nonce, request.Ciphertext, nil)
if decryptErr != nil {
return net.RespondWithInternalError(
sdkErrors.ErrCryptoDecryptionFailed, w,
reqres.BootstrapVerifyResponse{},
)
}
// Compute SHA-256 hash of plaintext
hash := sha256.Sum256(plaintext)
hashHex := hex.EncodeToString(hash[:])
return net.Success(
reqres.BootstrapVerifyResponse{
Hash: hashHex,
}.Success(), w,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package bootstrap
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.
//
// This function ensures that only authorized bootstrap instances can verify
// the system initialization by validating cryptographic parameters and peer
// identity.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Verifies the peer has a bootstrap SPIFFE ID
// - Validates the nonce size (must be 12 bytes for AES-GCM standard)
// - Validates the ciphertext size (must not exceed 1024 bytes to prevent DoS
// attacks)
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The bootstrap verification request containing nonce and
// ciphertext
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - nil if all validations pass
// - sdkErrors.ErrAccessUnauthorized if authentication fails or peer is not
// bootstrap
// - sdkErrors.ErrDataInvalidInput if nonce or ciphertext validation fails
func guardVerifyRequest(
request reqres.BootstrapVerifyRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
err := net.RespondUnauthorizedOnPredicateFail(spiffeid.IsBootstrap,
reqres.BootstrapVerifyResponse{}.Unauthorized(), w, r)
if err != nil {
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()
}
// 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()
}
return nil
}
// \\ 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
}
// \\ 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"
"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"
)
// RouteDecrypt handles HTTP requests to decrypt ciphertext data using the
// SPIKE Nexus cipher. This endpoint provides decryption-as-a-service
// functionality without persisting any data.
//
// The function supports two modes based on Content-Type:
//
// 1. Streaming mode (Content-Type: application/octet-stream):
// - Input: version byte + nonce + ciphertext (binary stream)
// - Output: raw decrypted binary data
//
// 2. JSON mode (any other Content-Type):
// - Input: JSON with { version: byte, nonce: []byte,
// ciphertext: []byte, algorithm: string (optional) }
// - Output: JSON with { plaintext: []byte, err: string }
//
// The decryption process:
// 1. Determines mode based on Content-Type header
// 2. For JSON mode: validates request and checks permissions
// 3. Retrieves the system cipher from the backend
// 4. Validates the protocol version and nonce size
// 5. Decrypts the ciphertext using authenticated decryption (AEAD)
// 6. Returns the decrypted plaintext in the appropriate format
//
// Access control is enforced through guardDecryptSecretRequest for JSON mode.
// Streaming mode may have different permission requirements.
//
// Parameters:
// - w: HTTP response writer for sending the decrypted response
// - r: HTTP request containing ciphertext data to decrypt
// - audit: Audit entry for logging the decryption request
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or one of:
// - ErrDataReadFailure if request body cannot be read
// - ErrDataParseFailure if JSON request cannot be parsed
// - ErrDataInvalidInput if the version is unsupported or nonce size is
// invalid
// - ErrStateBackendNotReady if cipher is unavailable
// - ErrCryptoDecryptFailed if decryption fails
func RouteDecrypt(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
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)
}
// \\ 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/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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardDecryptCipherRequest validates a cipher decryption request by
// performing authentication, authorization, and request field validation.
//
// This function implements a two-tier authorization model:
// 1. Lite workloads are automatically granted decryption access
// 2. Other workloads must have execute permission for the cipher decrypt path
//
// The function performs the following validations in order:
// - Validates request fields (future: size limits, format checks, etc.)
// - Checks if the peer is a lite workload (automatically allowed)
// - If not a lite workload, checks if the peer has execute permission for
// the system cipher decrypt path
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The cipher decryption request to validate
// - peerSPIFFEID: The already-validated peer SPIFFE ID (pointer)
// - w: The HTTP response writer for error responses
// - r: The HTTP request (for context)
//
// Returns:
// - 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,
) *sdkErrors.SDKError {
// Validate version
if err := validateVersion(
request.Version, w, reqres.CipherDecryptResponse{}.BadRequest(),
); err != nil {
return err
}
// Validate nonce size
if err := validateNonceSize(
request.Nonce, w, reqres.CipherDecryptResponse{}.BadRequest(),
); err != nil {
return err
}
// Validate ciphertext size to prevent DoS attacks
if err := validateCiphertextSize(
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
}
// \\ 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"
"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"
)
// RouteEncrypt handles HTTP requests to encrypt plaintext data using the
// SPIKE Nexus cipher. This endpoint provides encryption-as-a-service
// functionality without persisting any data.
//
// The function supports two modes based on Content-Type:
//
// 1. Streaming mode (Content-Type: application/octet-stream):
// - Input: raw binary data to encrypt
// - Output: version byte + nonce + ciphertext (binary stream)
//
// 2. JSON mode (any other Content-Type):
// - Input: JSON with { plaintext: []byte, algorithm: string (optional) }
// - Output: JSON with { version: byte, nonce: []byte,
// ciphertext: []byte, err: string }
//
// The encryption process:
// 1. Determines mode based on Content-Type header
// 2. For JSON mode: validates request and checks permissions
// 3. Retrieves the system cipher from the backend
// 4. Generates a cryptographically secure random nonce
// 5. Encrypts the data using authenticated encryption (AEAD)
// 6. Returns the encrypted data in the appropriate format
//
// Access control is enforced through guardEncryptSecretRequest for JSON mode.
// Streaming mode may have different permission requirements.
//
// Parameters:
// - w: HTTP response writer for sending the encrypted response
// - r: HTTP request containing plaintext data to encrypt
// - audit: Audit entry for logging the encryption request
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or one of:
// - ErrDataReadFailure if request body cannot be read
// - ErrDataParseFailure if JSON request cannot be parsed
// - ErrStateBackendNotReady if cipher is unavailable
// - ErrCryptoNonceGenerationFailed if nonce generation fails
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)
}
// \\ 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/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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardEncryptCipherRequest validates a cipher encryption request by
// performing authentication, authorization, and request field validation.
//
// This function implements a two-tier authorization model:
// 1. Lite workloads are automatically granted encryption access
// 2. Other workloads must have execute permission for the cipher encrypt path
//
// The function performs the following validations in order:
// - Validates request fields (future: size limits, format checks, etc.)
// - Checks if the peer is a lite workload (automatically allowed)
// - If not a lite workload, checks if the peer has execute permission for
// the system cipher encrypt path
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The cipher encryption request to validate
// - peerSPIFFEID: The already-validated peer SPIFFE ID (pointer)
// - w: The HTTP response writer for error responses
// - r: The HTTP request (for context)
//
// Returns:
// - nil if all validations pass
// - apiErr.ErrUnauthorized if authorization fails
// - apiErr.ErrBadInput if request validation fails
func guardEncryptCipherRequest(
request reqres.CipherEncryptRequest,
peerSPIFFEID *spiffeid.ID,
w http.ResponseWriter,
_ *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()
}
return nil
}
// \\ 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"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// handleStreamingDecrypt processes a complete streaming mode decryption
// request, including reading, validating, decrypting, and responding.
//
// The cipher is retrieved only after SPIFFE ID validation passes, following
// the principle of least privilege. Full request validation (including
// request fields) happens after the request is constructed.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
// - getCipher: Function to retrieve the cipher after authentication
//
// Returns:
// - *sdkErrors.SDKError: An error if any step fails
func handleStreamingDecrypt(
w http.ResponseWriter, r *http.Request,
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.
// Extract and validate SPIFFE ID before accessing cipher
peerSPIFFEID, err := extractAndValidateSPIFFEID(w, r)
if err != nil {
return err
}
// Get cipher only after SPIFFE ID validation passes
c, cipherErr := getCipher()
if cipherErr != nil {
return cipherErr
}
// Read request data (now that we have cipher for nonce size)
version, nonce, ciphertext, readErr := readStreamingDecryptRequestData(
w, r, c,
)
if readErr != nil {
return readErr
}
// Construct request object for guard validation
request := reqres.CipherDecryptRequest{
Version: version,
Nonce: nonce,
Ciphertext: ciphertext,
}
// Full guard validation (auth and request fields)
guardErr := guardDecryptCipherRequest(request, peerSPIFFEID, w, r)
if guardErr != nil {
return guardErr
}
plaintext, decryptErr := decryptDataStreaming(nonce, ciphertext, c, w)
if decryptErr != nil {
return decryptErr
}
return respondStreamingDecrypt(plaintext, w)
}
// handleJSONDecrypt processes a complete JSON mode decryption request,
// including reading, validating, decrypting, and responding.
//
// The cipher is retrieved only after SPIFFE ID validation passes, following
// the principle of least privilege. Full request validation (including
// request fields) happens after the request is parsed.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
// - getCipher: Function to retrieve the cipher after authentication
//
// Returns:
// - *sdkErrors.SDKError: An error if any step fails
func handleJSONDecrypt(
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)
if err != nil {
return err
}
// Parse request (doesn't need cipher)
request, readErr := readJSONDecryptRequestWithoutGuard(w, r)
if readErr != nil {
return readErr
}
// Full guard validation (auth and request fields)
guardErr := guardDecryptCipherRequest(*request, peerSPIFFEID, w, r)
if guardErr != nil {
return guardErr
}
// Get cipher only after auth passes
c, cipherErr := getCipher()
if cipherErr != nil {
return cipherErr
}
plaintext, decryptErr := decryptDataJSON(
request.Nonce, request.Ciphertext, c, w,
)
if decryptErr != nil {
return decryptErr
}
return respondJSONDecrypt(plaintext, w)
}
// handleStreamingEncrypt processes a complete streaming mode encryption
// request, including reading, nonce generation, encrypting, and responding.
//
// The cipher is retrieved only after SPIFFE ID validation passes, following
// the principle of least privilege. Full request validation (including
// request fields) happens after the request is constructed.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
// - getCipher: Function to retrieve the cipher after authentication
//
// Returns:
// - *sdkErrors.SDKError: An error if any step fails
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)
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)
if encryptErr != nil {
return encryptErr
}
return respondStreamingEncrypt(nonce, ciphertext, w)
}
// handleJSONEncrypt processes a complete JSON mode encryption request,
// including reading, nonce generation, encrypting, and responding.
//
// The cipher is retrieved only after SPIFFE ID validation passes, following
// the principle of least privilege. Full request validation (including
// request fields) happens after the request is parsed.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
// - getCipher: Function to retrieve the cipher after authentication
//
// Returns:
// - *sdkErrors.SDKError: An error if any step fails
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)
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,
)
if encryptErr != nil {
return encryptErr
}
return respondJSONEncrypt(nonce, ciphertext, w)
}
// \\ 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,
)
}
// \\ 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
}
// \\ 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
}
// \\ 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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"fmt"
"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/security/mem"
"github.com/spiffe/spike-sdk-go/journal"
"github.com/spiffe/spike/app/nexus/internal/initialization/recovery"
)
// RouteRecover handles HTTP requests for recovering pilot recovery shards.
//
// This function processes HTTP requests to retrieve recovery shards needed for
// a recovery operation. It reads and validates the request, retrieves the first
// two recovery shards from the pilot recovery system, and returns them in the
// response.
//
// Parameters:
// - w http.ResponseWriter: The HTTP response writer to write the response to.
// - r *http.Request: The incoming HTTP request.
// - audit *journal.AuditEntry: An audit entry for logging the request.
//
// Returns:
// - error: An error if one occurs during processing, nil otherwise.
//
// The function will return various errors in the following cases:
// - errors.ErrReadFailure: If the request body cannot be read.
// - errors.ErrParseFailure: If the request body cannot be parsed.
// - errors.ErrNotFound: If fewer than 2 recovery shards are available.
// - Any error returned by guardRecoverRequest: For request validation
// failures.
//
// On success, the function responds with HTTP 200 OK and the first two recovery
// shards in the response body.
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[
reqres.RecoverRequest, reqres.RecoverResponse](
w, r, reqres.RecoverResponse{}.BadRequest(), guardRecoverRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
shards := recovery.NewPilotRecoveryShards()
// Security: reset shards before the function exits.
defer func() {
for i := range shards {
mem.ClearRawBytes(shards[i])
}
}()
if len(shards) < env.ShamirThresholdVal() {
return net.RespondWithInternalError(
sdkErrors.ErrShamirNotEnoughShards, w, reqres.RecoverResponse{},
)
}
// Track seen indices to check for duplicates
seenIndices := make(map[int]bool)
for idx, shard := range shards {
if seenIndices[idx] {
failErr := sdkErrors.ErrShamirDuplicateIndex.Clone()
failErr.Msg = fmt.Sprint("duplicate shard index: ", idx)
return net.RespondWithInternalError(failErr, w, reqres.RecoverResponse{})
}
// We cannot check for duplicate values, because although it's
// astronomically unlikely, there is still a possibility of two
// different indices having the same shard value.
seenIndices[idx] = true
// Check for nil pointers
if shard == nil {
return net.RespondWithInternalError(
sdkErrors.ErrShamirNilShard, w, reqres.RecoverResponse{},
)
}
// Check for empty shards (all zeros)
zeroed := true
for _, b := range *shard {
if b != 0 {
zeroed = false
break
}
}
if zeroed {
return net.RespondWithInternalError(
sdkErrors.ErrShamirEmptyShard, w, reqres.RecoverResponse{},
)
}
// Verify shard index is within valid range:
if idx < 1 || idx > env.ShamirSharesVal() {
return net.RespondWithInternalError(
sdkErrors.ErrShamirInvalidIndex, w, reqres.RecoverResponse{},
)
}
}
responseBody, successErr := net.SuccessWithResponseBody(
reqres.RecoverResponse{Shards: shards}.Success(), w,
)
defer func() {
mem.ClearBytes(responseBody)
}()
if successErr != nil {
return successErr
}
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
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"
"github.com/spiffe/spike-sdk-go/spiffeid"
)
// guardRecoverRequest validates a system recovery request by performing
// authentication and authorization checks.
//
// This function implements strict authorization for system recovery operations,
// which are critical administrative functions that should only be accessible
// to authorized operator identities.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Verifies the peer has a pilot-recover SPIFFE ID (operator role)
//
// Only identities with the pilot-recover role are authorized to perform system
// recovery operations. All other identities are rejected.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The recovery request (currently unused, reserved for future
// validation needs)
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication fails or the peer is
// not authorized (not pilot-recover). Returns nil if all validations pass.
func guardRecoverRequest(
_ reqres.RecoverRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return net.RespondUnauthorizedOnPredicateFail(spiffeid.IsPilotRecover,
reqres.RestoreResponse{}.Unauthorized(), w, r,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"net/http"
"sync"
"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"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"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
shardsMutex sync.RWMutex
)
// RouteRestore handles HTTP requests for restoring SPIKE Nexus using recovery
// shards.
//
// This function processes requests to contribute a recovery shard to the
// restoration process. It validates the incoming shard, adds it to the
// collection, and triggers the full restoration once all expected shards have
// been collected.
//
// Parameters:
// - w http.ResponseWriter: The HTTP response writer to write the response to.
// - r *http.Request: The incoming HTTP request.
// - audit *journal.AuditEntry: An audit entry for logging the request.
//
// Returns:
// - error: An error if one occurs during processing, nil otherwise.
//
// The function will return various errors in the following cases:
// - errors.ErrReadFailure: If the request body cannot be read.
// - errors.ErrParseFailure: If the request body cannot be parsed.
// - errors.ErrMarshalFailure: If the response body cannot be marshaled.
// - Any error returned by guardRestoreRequest: For request validation
// failures.
//
// The function responds with HTTP 200 OK in all successful cases:
// - Shard successfully added to the collection
// - Restoration already complete (additional shards acknowledged but ignored)
// - Duplicate shard received (acknowledged but ignored, status shows
// the remaining shards needed)
//
// When the last required shard is added, the function automatically triggers
// the restoration process using RestoreBackingStoreFromPilotShards.
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 {
log.Info(fName, "message", "skipping restoration: in-memory mode")
return nil
}
request, err := net.ReadParseAndGuard[
reqres.RestoreRequest, reqres.RestoreResponse](
w, r, reqres.RestoreResponse{}.BadRequest(), guardRestoreRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
shardsMutex.Lock()
defer shardsMutex.Unlock()
// Check if we already have enough shards
currentShardCount := len(shards)
threshold := env.ShamirThresholdVal()
restored := currentShardCount >= threshold
if restored {
// Already restored; acknowledge and ignore additional shards.
return net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: 0,
Restored: restored,
},
}.Success(), w,
)
}
for _, shard := range shards {
if int(shard.ID) != request.ID {
continue
}
// Duplicate shard; acknowledge and ignore.
return net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: threshold - currentShardCount,
Restored: restored,
},
}.Success(), w,
)
}
shards = append(shards, recovery.ShamirShard{
ID: uint64(request.ID),
Value: request.Shard,
})
currentShardCount = len(shards)
// Note: We cannot clear request.Shard because it's a pointer type,
// and we need it later in the "restore" operation.
// RouteRestore cleans this up when it is no longer necessary.
// Trigger restoration if we have collected all shards
restored = currentShardCount >= threshold
if restored {
recovery.RestoreBackingStoreFromPilotShards(shards)
// Security: Zero out all shards since we have finished restoration:
for i := range shards {
mem.ClearRawBytes(shards[i].Value)
shards[i].ID = 0
}
}
return net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: threshold - currentShardCount,
Restored: restored,
},
}.Success(), w,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
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"
)
// guardRestoreRequest validates a system restore request by performing
// authentication, authorization, and input validation checks.
//
// This function implements strict authorization and validation for system
// restore operations, which are critical administrative functions that restore
// the system state from Shamir secret shares.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Verifies the peer has a pilot-restore SPIFFE ID (operator role)
// - Validates the shard ID is within valid range (1-1000)
// - Validates the shard data is not all zeros (must contain meaningful data)
//
// Only identities with the pilot-restore role are authorized to perform system
// restore operations. The shard ID range reflects the practical limit of SPIKE
// Keeper instances in a deployment.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The restore request containing shard ID and shard data
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication fails, the peer is not
// authorized (not pilot-restore), the shard ID is out of range, or the
// shard data is invalid. Returns nil if all validations pass.
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
}
// 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
}
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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike/app/nexus/internal/initialization/recovery"
)
// Helper functions
func resetShards() {
shardsMutex.Lock()
defer shardsMutex.Unlock()
shards = []recovery.ShamirShard{}
}
func createTestShardValue(id int) *[crypto.AES256KeySize]byte {
value := &[crypto.AES256KeySize]byte{}
// Fill with deterministic test data
for i := range value {
value[i] = byte((id*100 + i) % 256)
}
// Ensure the first byte is non-zero for validation
value[0] = byte(id)
return value
}
// \\ 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/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteDeleteSecret handles HTTP DELETE requests for secret deletion
// operations. It authenticates the peer, validates permissions, processes
// the deletion request, and manages the secret deletion workflow.
//
// The function expects a request body containing a path and optional version
// numbers of the secrets to be deleted. If no versions are specified, the
// current version is deleted.
//
// Parameters:
// - w: http.ResponseWriter for writing the HTTP response
// - r: *http.Request containing the incoming HTTP request with peer SPIFFE ID
// - audit: *journal.AuditEntry for logging audit information about the
// deletion operation
//
// Returns:
// - *sdkErrors.SDKError: Returns nil on successful execution, or an error
// describing what went wrong
//
// The function performs the following steps:
// 1. Authenticates the peer via SPIFFE ID and validates write permissions
// 2. Reads and parses the request body
// 3. Processes the secret deletion (soft-delete operation)
// 4. Returns an appropriate JSON response
//
// Example request body:
//
// {
// "path": "secret/path",
// "versions": [1, 2, 3]
// }
//
// Response codes:
// - 200 OK: Secret successfully deleted
// - 400 Bad Request: Invalid request body or path format
// - 401 Unauthorized: Authentication or authorization failure
// - 404 Not Found: Secret does not exist at the specified path
// - 500 Internal Server Error: Backend or server-side failure
func RouteDeleteSecret(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) *sdkErrors.SDKError {
const fName = "RouteDeleteSecret"
journal.AuditRequest(fName, r, audit, journal.AuditDelete)
request, err := net.ReadParseAndGuard[
reqres.SecretDeleteRequest, reqres.SecretDeleteResponse](
w, r, reqres.SecretDeleteResponse{}.BadRequest(), guardDeleteSecretRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
path := request.Path
versions := request.Versions
if len(versions) == 0 {
versions = []int{}
}
deleteErr := state.DeleteSecret(path, versions)
if deleteErr != nil {
return net.RespondWithHTTPError(deleteErr, w, reqres.SecretDeleteResponse{})
}
return net.Success(reqres.SecretDeleteResponse{}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// guardDeleteSecretRequest validates a secret deletion request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the secret path format
// - Checks if the peer has write permission for the specified secret path
//
// Write permission is required for delete operations following the principle
// that deletion is a write operation on the secret resource.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The secret deletion request containing the secret path
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication, authorization, or path
// validation fails. Returns nil if all validations pass.
func guardDeleteSecretRequest(
request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return guardSecretRequest(
request.Path,
[]data.PolicyPermission{data.PermissionWrite},
w, r,
reqres.SecretDeleteResponse{}.Unauthorized(),
reqres.SecretDeleteResponse{}.BadRequest(),
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"fmt"
"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/log"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteGetSecret handles requests to retrieve a secret at a specific path
// and version.
//
// This endpoint requires the peer to have read permission for the specified
// secret path. The function retrieves a secret based on the provided path and
// optional version number. If no version is specified (version 0), the current
// version is returned.
//
// The function follows these steps:
// 1. Validates peer SPIFFE ID, authorization, and path format
// 2. Validates and unmarshals the request body
// 3. Attempts to retrieve the secret from state
// 4. Returns the secret data or an appropriate error response
//
// Parameters:
// - w: The HTTP response writer for sending the response
// - r: The HTTP request containing the peer SPIFFE ID
// - audit: The audit entry for logging audit information
//
// Returns:
// - *sdkErrors.SDKError: An error if validation or retrieval fails. Returns
// nil on success.
//
// Request body format:
//
// {
// "path": string, // Path to the secret
// "version": int // Optional: specific version to retrieve
// }
//
// Response format on success (200 OK):
//
// {
// "data": { // The secret data
// // Secret key-value pairs
// }
// }
//
// Error responses:
// - 401 Unauthorized: Authentication or authorization failure
// - 400 Bad Request: Invalid request body or path format
// - 404 Not Found: Secret does not exist at specified path/version
//
// All operations are logged using structured logging.
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[
reqres.SecretGetRequest, reqres.SecretGetResponse](
w, r, reqres.SecretGetResponse{}.BadRequest(), guardGetSecretRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
version := request.Version
path := request.Path
secret, getErr := state.GetSecret(path, version)
secretFound := getErr == nil
// Extra logging to help with debugging and detecting enumeration attacks.
if !secretFound {
notFoundErr := sdkErrors.ErrAPINotFound.Wrap(getErr)
notFoundErr.Msg = fmt.Sprintf(
"secret not found at path: %s version: %d", path, version,
)
log.DebugErr(fName, *notFoundErr)
}
if !secretFound {
return net.RespondWithHTTPError(getErr, w, reqres.SecretGetResponse{})
}
return net.Success(reqres.SecretGetResponse{
Secret: data.Secret{Data: secret},
}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// guardGetSecretRequest validates a secret retrieval request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the secret path format
// - Checks if the peer has read permission for the specified secret path
//
// Read permission is required to retrieve secret data. The authorization check
// is performed against the specific secret path to enable fine-grained access
// control.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The secret read request containing the secret path
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication, authorization, or path
// validation fails. Returns nil if all validations pass.
func guardGetSecretRequest(
request reqres.SecretGetRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return guardSecretRequest(
request.Path,
[]data.PolicyPermission{data.PermissionRead},
w, r,
reqres.SecretGetResponse{}.Unauthorized(),
reqres.SecretGetResponse{}.BadRequest(),
)
}
// \\ 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
}
// \\ 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/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteListPaths handles requests to retrieve all available secret paths.
//
// This endpoint requires the peer to have list permission for the system
// secret access path. The function returns a list of all paths where secrets
// are stored, regardless of their version or deletion status.
//
// The function follows these steps:
// 1. Validates peer SPIFFE ID and authorization (via guardListSecretRequest)
// 2. Validates the request body format
// 3. Retrieves all secret paths from the state
// 4. Returns the list of paths
//
// Parameters:
// - w: The HTTP response writer for sending the response
// - r: The HTTP request containing the peer SPIFFE ID
// - audit: The audit entry for logging audit information
//
// Returns:
// - *sdkErrors.SDKError: An error if validation or processing fails.
// Returns nil on success.
//
// Request body format:
//
// {} // Empty request body expected
//
// The response format on success (200 OK):
//
// {
// "keys": []string // Array of all secret paths
// }
//
// Error responses:
// - 401 Unauthorized: Authentication or authorization failure
// - 400 Bad Request: Invalid request body format
//
// All operations are logged using structured logging. This endpoint only
// returns the paths to secrets and not their contents; use RouteGetSecret to
// retrieve actual secret values.
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[
reqres.SecretListRequest, reqres.SecretListResponse](
w, r, reqres.SecretListResponse{}.BadRequest(), guardListSecretRequest,
)
if err != nil {
return err
}
return net.Success(
reqres.SecretListResponse{Keys: state.ListKeys()}.Success(), w,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiAuth "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardListSecretRequest validates a secret listing request by performing
// authentication and authorization checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Checks if the peer has list permission for the system secret access path
//
// List permission is required to enumerate secrets in the system. The
// authorization check is performed against the system-level secret access path
// to control which identities can discover what secrets exist.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The secret list request (currently unused, reserved for future
// validation needs)
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication or authorization fails.
// Returns nil if all validations pass.
func guardListSecretRequest(
_ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
peerSPIFFEID, err := net.ExtractPeerSPIFFEIDFromRequestAndRespondOnFail[reqres.SecretListResponse](
r, w, reqres.SecretListResponse{}.Unauthorized(),
)
if err != nil {
return err
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), apiAuth.PathSystemSecretAccess,
[]data.PolicyPermission{data.PermissionList},
)
if !allowed {
failErr := net.Fail(
reqres.SecretListResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
if failErr != nil {
return sdkErrors.ErrAccessUnauthorized.Wrap(failErr)
}
return sdkErrors.ErrAccessUnauthorized
}
return nil
}
// \\ 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()
}
// \\ 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/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteGetSecretMetadata handles requests to retrieve secret metadata at a
// specific path and version.
//
// This endpoint requires the peer to have read permission for the specified
// secret path. The function retrieves secret metadata based on the provided
// path and optional version number. If no version is specified (version 0),
// the current version's metadata is returned.
//
// The function follows these steps:
// 1. Authenticates the peer via SPIFFE ID and validates read permissions
// 2. Validates and unmarshals the request body
// 3. Attempts to retrieve the secret metadata
// 4. Returns the secret metadata or an appropriate error response
//
// Parameters:
// - w: http.ResponseWriter to write the HTTP response
// - r: *http.Request containing the incoming HTTP request with peer SPIFFE ID
// - audit: *journal.AuditEntry for logging audit information
//
// Returns:
// - *sdkErrors.SDKError: Returns nil on successful execution, or an error
// describing what went wrong
//
// Request body format:
//
// {
// "path": string, // Path to the secret
// "version": int // Optional: specific version to retrieve
// // (0 = current)
// }
//
// Response format on success (200 OK):
//
// "versions": { // map[int]SecretMetadataVersionResponse
//
// "version": { // SecretMetadataVersionResponse object
// "createdTime": "", // time.Time
// "version": 0, // int
// "deletedTime": null // *time.Time (pointer, can be null)
// }
// },
//
// "metadata": { // SecretRawMetadataResponse object
//
// "currentVersion": 0, // int
// "oldestVersion": 0, // int
// "createdTime": "", // time.Time
// "updatedTime": "", // time.Time
// "maxVersions": 0 // int
// },
//
// "err": null // ErrorCode
//
// Error responses:
// - 200 OK: Secret metadata successfully retrieved
// - 400 Bad Request: Invalid request body or path format
// - 401 Unauthorized: Authentication or authorization failure
// - 404 Not Found: Secret does not exist at specified path/version
// - 500 Internal Server Error: Backend or server-side failure
//
// All operations are logged using structured logging.
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[
reqres.SecretMetadataRequest, reqres.SecretMetadataResponse,
](
w, r, reqres.SecretMetadataResponse{}.BadRequest(),
guardGetSecretMetadataRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
path := request.Path
version := request.Version
rawSecret, getErr := state.GetRawSecret(path, version)
if getErr != nil {
return net.RespondWithHTTPError(getErr, w, reqres.SecretMetadataResponse{})
}
return net.Success(toSecretMetadataSuccessResponse(rawSecret), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardGetSecretMetadataRequest validates a secret metadata retrieval request
// by performing authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the secret path format
// - Checks if the peer has read permission for the specified secret path
//
// Read permission is required to retrieve secret metadata. The authorization
// check is performed against the specific secret path to enable fine-grained
// access control. Metadata access uses the same permission level as secret
// data access.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter and an error is returned.
//
// Parameters:
// - request: The secret metadata request containing the secret path
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - *sdkErrors.SDKError: An error if authentication, authorization, or path
// validation fails. Returns nil if all validations pass.
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)
}
return authErr
}
return nil
}
// \\ 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/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RoutePutSecret handles HTTP requests to create or update secrets at a
// specified path.
//
// This endpoint requires authentication via SPIFFE ID and write permission for
// the specified secret path. It accepts a PUT request with a JSON body
// containing the secret path and values to store. The function performs an
// upsert operation, creating a new secret if it doesn't exist or updating an
// existing one.
//
// Parameters:
// - w: http.ResponseWriter to write the HTTP response
// - r: *http.Request containing the incoming HTTP request
// - audit: *journal.AuditEntry for logging audit information
//
// Returns:
// - nil if the secret is successfully created or updated
// - sdkErrors.ErrAPIPostFailed if the upsert operation fails
// - SDK errors from request parsing or validation
//
// Request body format:
//
// {
// "path": string, // Path where the secret should be stored
// "values": map[string]any // Key-value pairs representing the secret data
// }
//
// Responses:
// - 200 OK: Secret successfully created or updated
// - 400 Bad Request: Invalid request body or parameters
// - 401 Unauthorized: Missing SPIFFE ID or insufficient permissions
// - 500 Internal Server Error: Database operation failure
//
// The function logs its progress at various stages using structured logging.
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[
reqres.SecretPutRequest, reqres.SecretPutResponse,
](
w, r, reqres.SecretPutResponse{}.BadRequest(), guardSecretPutRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
values := request.Values
path := request.Path
upsertErr := state.UpsertSecret(path, values)
if upsertErr != nil {
return net.RespondWithHTTPError(upsertErr, w, reqres.SecretPutResponse{})
}
return net.Success(reqres.SecretPutResponse{}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// guardSecretPutRequest validates a secret storage request by performing
// authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Validates the secret path format
// - Validates each key name in the secret values map
// - Checks if the peer has write permission for the specified secret path
//
// Write permission is required to create or update secret data. The key name
// validation ensures that all keys in the secret values conform to naming
// requirements. The authorization check is performed against the specific
// secret path to enable fine-grained access control.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter, and an error is returned.
//
// Parameters:
// - request: The secret put request containing the path and values
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - nil if all validations pass
// - sdkErrors.ErrAccessUnauthorized if authorization fails
// - sdkErrors.ErrAPIBadRequest if path or key name validation fails
// - SDK errors from authentication if peer SPIFFE ID extraction fails
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
}
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)
}
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},
)
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
}
// \\ 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/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/journal"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// RouteUndeleteSecret handles HTTP requests to restore previously deleted
// secrets.
//
// This endpoint requires authentication via SPIFFE ID and undelete permission
// for the specified secret path. It accepts a POST request with a JSON body
// containing a path to the secret and optionally specific versions to undelete.
// If no versions are specified, an empty version list is used.
//
// The function validates the request, processes the undelete operation, and
// returns a "200 OK" response upon success.
//
// Parameters:
// - w: http.ResponseWriter to write the HTTP response
// - r: *http.Request containing the incoming HTTP request
// - audit: *journal.AuditEntry for logging audit information
//
// Returns:
// - nil if the secret is successfully undeleted
// - sdkErrors.ErrAPIPostFailed if the undelete operation fails
// - SDK errors from request parsing or validation
//
// Request body format:
//
// {
// "path": string, // Path to the secret to undelete
// "versions": []int // Optional list of specific versions to undelete
// }
//
// Responses:
// - 200 OK: Secret successfully undeleted
// - 400 Bad Request: Invalid request body or parameters
// - 401 Unauthorized: Missing SPIFFE ID or insufficient permissions
// - 500 Internal Server Error: Database operation failure
//
// The function logs its progress at various stages using structured logging.
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[
reqres.SecretUndeleteRequest, reqres.SecretUndeleteResponse,
](
w, r, reqres.SecretUndeleteResponse{}.BadRequest(),
guardSecretUndeleteRequest,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
path := request.Path
versions := request.Versions
if len(versions) == 0 {
versions = []int{}
}
undeleteErr := state.UndeleteSecret(path, versions)
if undeleteErr != nil {
return net.RespondWithHTTPError(
undeleteErr, w, reqres.SecretUndeleteResponse{},
)
}
return net.Success(reqres.SecretUndeleteResponse{}.Success(), w)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// guardSecretUndeleteRequest validates a secret restoration request by
// performing authentication, authorization, and input validation checks.
//
// The function performs the following validations in order:
// - Extracts and validates the peer SPIFFE ID from the request
// - Checks if the peer has write permission for the specified secret path
// - Validates the secret path format
//
// Write permission is required for undelete operations following the principle
// that restoration is a write operation on the secret resource. The
// authorization check is performed against the specific secret path to enable
// fine-grained access control.
//
// If any validation fails, an appropriate error response is written to the
// ResponseWriter, and an error is returned.
//
// Parameters:
// - request: The secret undelete request containing the secret path
// - w: The HTTP response writer for error responses
// - r: The HTTP request containing the peer SPIFFE ID
//
// Returns:
// - nil if all validations pass
// - sdkErrors.ErrAccessUnauthorized if authorization fails
// - sdkErrors.ErrAPIBadRequest if path validation fails
// - SDK errors from authentication if peer SPIFFE ID extraction fails
func guardSecretUndeleteRequest(
request reqres.SecretUndeleteRequest, w http.ResponseWriter, r *http.Request,
) *sdkErrors.SDKError {
return guardSecretRequest(
request.Path,
[]data.PolicyPermission{data.PermissionWrite},
w, r,
reqres.SecretUndeleteResponse{}.Unauthorized(),
reqres.SecretUndeleteResponse{}.BadRequest(),
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package lite
import (
"crypto/aes"
"crypto/cipher"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike/app/nexus/internal/state/backend/noop"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
)
// Store implements the backend.Backend interface, providing encryption
// without persistent storage.
//
// This store embeds noop.Store for all storage operations (which are no-ops)
// and provides AES-GCM encryption capabilities through its Cipher field. It
// acts as an encryption-as-a-service layer, suitable for scenarios where
// encryption is required but data persistence is handled in-memory or by
// another component.
type Store struct {
noop.Store // Embedded no-op store for storage operations
Cipher cipher.AEAD // AES-GCM cipher for data encryption/decryption
}
// New creates a new lite backend with AES-GCM encryption.
//
// This function initializes an AES cipher block using the provided root key
// and wraps it with GCM (Galois/Counter Mode) for authenticated encryption.
// The resulting backend provides encryption services without any persistent
// storage functionality.
//
// Parameters:
// - rootKey: A 256-bit (32-byte) AES key used for encryption/decryption
//
// Returns:
// - backend.Backend: An initialized lite backend with AES-GCM encryption
// - *sdkErrors.SDKError: nil on success, or one of the following errors:
// - sdkErrors.ErrCryptoFailedToCreateCipher if AES cipher creation fails
// - sdkErrors.ErrCryptoFailedToCreateGCM if GCM mode initialization fails
func New(rootKey *[crypto.AES256KeySize]byte) (
backend.Backend, *sdkErrors.SDKError,
) {
block, cipherErr := aes.NewCipher(rootKey[:])
if cipherErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateCipher.Wrap(cipherErr)
return nil, failErr
}
gcm, gcmErr := cipher.NewGCM(block)
if gcmErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateGCM.Wrap(gcmErr)
return nil, failErr
}
return &Store{
Cipher: gcm,
}, nil
}
// GetCipher returns the AES-GCM cipher used for data encryption and
// decryption.
//
// This method provides access to the underlying AEAD (Authenticated Encryption
// with Associated Data) cipher for performing cryptographic operations.
//
// Returns:
// - cipher.AEAD: The AES-GCM cipher instance configured during store
// initialization
func (ds *Store) GetCipher() cipher.AEAD {
return ds.Cipher
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package memory
import (
"context"
"crypto/cipher"
"errors"
"sync"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/kv"
)
// Store provides an in-memory implementation of a storage backend.
//
// This implementation stores data in memory using the kv package for secrets
// and a map for policies. It is fully functional and thread-safe, making it
// suitable for development, testing, or scenarios where persistent storage is
// not required. All data is lost when the process terminates.
//
// The store uses separate read-write mutexes for secrets and policies to
// allow concurrent reads while ensuring exclusive writes.
type Store struct {
secretStore *kv.KV // In-memory key-value store for secrets
secretMu sync.RWMutex // Mutex protecting secret operations
policies map[string]*data.Policy // In-memory map of policies by ID
policyMu sync.RWMutex // Mutex protecting policy operations
cipher cipher.AEAD // Encryption cipher (for interface compatibility)
}
// NewInMemoryStore creates a new in-memory store instance.
//
// The store is immediately ready for use and requires no additional
// initialization. Secret versioning is configured according to the
// maxVersions parameter.
//
// Parameters:
// - cipher: The encryption cipher (stored for interface compatibility but
// not used for in-memory encryption)
// - maxVersions: Maximum number of versions to retain per secret
//
// Returns:
// - *Store: An initialized in-memory store ready for use
func NewInMemoryStore(cipher cipher.AEAD, maxVersions int) *Store {
return &Store{
secretStore: kv.New(kv.Config{
MaxSecretVersions: maxVersions,
}),
policies: make(map[string]*data.Policy),
cipher: cipher,
}
}
// Initialize prepares the store for use.
//
// For the in-memory implementation, this is a no-op since the store is fully
// initialized in the constructor. This method exists to satisfy the backend
// interface.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) Initialize(_ context.Context) *sdkErrors.SDKError {
// Already initialized in constructor
return nil
}
// Close implements the closing operation for the store.
//
// For the in-memory implementation, this is a no-op since there are no
// resources to release. All data is simply garbage collected when the store
// is no longer referenced. This method exists to satisfy the backend
// interface.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) Close(_ context.Context) *sdkErrors.SDKError {
// Nothing to close for in-memory store
return nil
}
// StoreSecret saves a secret to the store at the specified path.
//
// This method is thread-safe and stores the complete secret structure,
// including all versions and metadata. If a secret already exists at the
// path, it is replaced entirely.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
// - path: The secret path where the secret should be stored
// - secret: The complete secret value including metadata and versions
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil for in-memory storage
func (s *Store) StoreSecret(
_ context.Context, path string, secret kv.Value,
) *sdkErrors.SDKError {
s.secretMu.Lock()
defer s.secretMu.Unlock()
// Store the entire secret structure
s.secretStore.ImportSecrets(map[string]*kv.Value{
path: &secret,
})
return nil
}
// LoadSecret retrieves a secret from the store by its path.
//
// This method is thread-safe and returns the complete secret structure,
// including all versions and metadata.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
// - path: The secret path to retrieve
//
// Returns:
// - *kv.Value: The secret with all its versions and metadata, or nil if not
// found
// - *sdkErrors.SDKError: nil on success, sdkErrors.ErrEntityNotFound if the
// secret does not exist, or an error if retrieval fails
func (s *Store) LoadSecret(
_ context.Context, path string,
) (*kv.Value, *sdkErrors.SDKError) {
s.secretMu.RLock()
defer s.secretMu.RUnlock()
rawSecret, err := s.secretStore.GetRawSecret(path)
if err != nil && errors.Is(err, sdkErrors.ErrEntityNotFound) {
return nil, sdkErrors.ErrEntityNotFound
} else if err != nil {
return nil, err
}
return rawSecret, nil
}
// LoadAllSecrets retrieves all secrets stored in the store.
//
// This method is thread-safe and returns a map of all secrets currently in
// memory. If any individual secret fails to load (which should not happen in
// normal operation), it is silently skipped.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
//
// Returns:
// - map[string]*kv.Value: A map of secret paths to their values
// - *sdkErrors.SDKError: Always returns nil for in-memory storage
func (s *Store) LoadAllSecrets(_ context.Context) (
map[string]*kv.Value, *sdkErrors.SDKError,
) {
s.secretMu.RLock()
defer s.secretMu.RUnlock()
result := make(map[string]*kv.Value)
// Get all paths
paths := s.secretStore.List()
// Load each secret
for _, path := range paths {
secret, err := s.secretStore.GetRawSecret(path)
if err != nil {
continue // Skip secrets that can't be loaded
}
result[path] = secret
}
return result, nil
}
// StorePolicy stores a policy in the store.
//
// This method is thread-safe and validates that the policy has a non-empty ID
// before storing. If a policy with the same ID already exists, it is
// replaced.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
// - policy: The policy to store
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or sdkErrors.ErrEntityInvalid if
// the policy ID is empty
func (s *Store) StorePolicy(
_ context.Context, policy data.Policy,
) *sdkErrors.SDKError {
s.policyMu.Lock()
defer s.policyMu.Unlock()
if policy.ID == "" {
failErr := *sdkErrors.ErrEntityInvalid.Clone()
failErr.Msg = "policy ID cannot be empty"
return &failErr
}
s.policies[policy.ID] = &policy
return nil
}
// LoadPolicy retrieves a policy from the store by its ID.
//
// This method is thread-safe and returns the policy if it exists.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
// - id: The unique identifier of the policy to retrieve
//
// Returns:
// - *data.Policy: The policy if found, nil otherwise
// - *sdkErrors.SDKError: nil on success, or sdkErrors.ErrEntityNotFound if
// the policy does not exist
func (s *Store) LoadPolicy(
_ context.Context, id string,
) (*data.Policy, *sdkErrors.SDKError) {
s.policyMu.RLock()
defer s.policyMu.RUnlock()
policy, exists := s.policies[id]
if !exists {
return nil, sdkErrors.ErrEntityNotFound
}
return policy, nil
}
// LoadAllPolicies retrieves all policies from the store.
//
// This method is thread-safe and returns a copy of the policies map to avoid
// race conditions if the caller modifies the returned map.
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
//
// Returns:
// - map[string]*data.Policy: A map of policy IDs to policies
// - *sdkErrors.SDKError: Always returns nil for in-memory storage
func (s *Store) LoadAllPolicies(
_ context.Context,
) (map[string]*data.Policy, *sdkErrors.SDKError) {
s.policyMu.RLock()
defer s.policyMu.RUnlock()
// Create a copy to avoid race conditions
result := make(map[string]*data.Policy, len(s.policies))
for id, policy := range s.policies {
result[id] = policy
}
return result, nil
}
// DeletePolicy removes a policy from the store by its ID.
//
// This method is thread-safe and removes the policy if it exists. If the
// policy does not exist, this is a no-op (no error is returned).
//
// Parameters:
// - context.Context: Context for cancellation (ignored in this
// implementation)
// - id: The unique identifier of the policy to delete
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil for in-memory storage
func (s *Store) DeletePolicy(_ context.Context, id string) *sdkErrors.SDKError {
s.policyMu.Lock()
defer s.policyMu.Unlock()
delete(s.policies, id)
return nil
}
// GetCipher returns the cipher used for encryption/decryption.
//
// For the in-memory implementation, this cipher is stored for interface
// compatibility but is not used for encryption since data is kept
// in memory in plaintext.
//
// Returns:
// - cipher.AEAD: The cipher provided during initialization
func (s *Store) GetCipher() cipher.AEAD {
return s.cipher
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package memory
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"testing"
)
func createTestCipher(t *testing.T) cipher.AEAD {
key := make([]byte, 32) // AES-256 key
if _, randErr := rand.Read(key); randErr != nil {
t.Fatalf("Failed to generate test key: %v", randErr)
}
block, cipherErr := aes.NewCipher(key)
if cipherErr != nil {
t.Fatalf("Failed to create cipher: %v", cipherErr)
}
gcm, gcmErr := cipher.NewGCM(block)
if gcmErr != nil {
t.Fatalf("Failed to create GCM: %v", gcmErr)
}
return gcm
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package noop
import (
"context"
"crypto/cipher"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/kv"
)
// Store provides a no-op implementation of a storage backend.
// This implementation can be used for testing or as a placeholder
// where no actual storage is needed. Store is also used when the
// backing kv is configured to be in-memory.
type Store struct {
}
// Close implements the closing operation for the store.
//
// This is a no-op implementation that always succeeds. It exists to satisfy
// the backend interface but performs no actual cleanup operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) Close(_ context.Context) *sdkErrors.SDKError {
return nil
}
// Initialize prepares the store for use.
//
// This is a no-op implementation that always succeeds. It exists to satisfy
// the backend interface but performs no actual initialization operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) Initialize(_ context.Context) *sdkErrors.SDKError {
return nil
}
// LoadSecret retrieves a secret from the store by its path.
//
// This is a no-op implementation that always returns nil values. It exists to
// satisfy the backend interface but performs no actual retrieval operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
// - string: The secret path (ignored in this implementation)
//
// Returns:
// - *kv.Value: Always returns nil
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) LoadSecret(
_ context.Context, _ string,
) (*kv.Value, *sdkErrors.SDKError) {
return nil, nil
}
// LoadAllSecrets retrieves all secrets stored in the store.
//
// This is a no-op implementation that always returns nil. It exists to
// satisfy the backend interface but performs no actual retrieval operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
//
// Returns:
// - map[string]*kv.Value: Always returns nil
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) LoadAllSecrets(_ context.Context) (
map[string]*kv.Value, *sdkErrors.SDKError,
) {
return nil, nil
}
// StoreSecret saves a secret to the store at the specified path.
//
// This is a no-op implementation that always succeeds. It exists to satisfy
// the backend interface but performs no actual storage operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
// - string: The secret path (ignored in this implementation)
// - kv.Value: The secret value (ignored in this implementation)
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) StoreSecret(
_ context.Context, _ string, _ kv.Value,
) *sdkErrors.SDKError {
return nil
}
// StorePolicy stores a policy in the store.
//
// This is a no-op implementation that always succeeds. It exists to satisfy
// the backend interface but performs no actual storage operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
// - data.Policy: The policy to store (ignored in this implementation)
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) StorePolicy(
_ context.Context, _ data.Policy,
) *sdkErrors.SDKError {
return nil
}
// LoadPolicy retrieves a policy from the store by its ID.
//
// This is a no-op implementation that always returns nil values. It exists to
// satisfy the backend interface but performs no actual retrieval operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
// - string: The policy ID (ignored in this implementation)
//
// Returns:
// - *data.Policy: Always returns nil
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) LoadPolicy(
_ context.Context, _ string,
) (*data.Policy, *sdkErrors.SDKError) {
return nil, nil
}
// LoadAllPolicies retrieves all policies from the store.
//
// This is a no-op implementation that always returns nil. It exists to
// satisfy the backend interface but performs no actual retrieval operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
//
// Returns:
// - map[string]*data.Policy: Always returns nil
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) LoadAllPolicies(
_ context.Context,
) (map[string]*data.Policy, *sdkErrors.SDKError) {
return nil, nil
}
// DeletePolicy removes a policy from the store by its ID.
//
// This is a no-op implementation that always succeeds. It exists to satisfy
// the backend interface but performs no actual deletion operations.
//
// Parameters:
// - context.Context: Ignored in this implementation
// - string: The policy ID (ignored in this implementation)
//
// Returns:
// - *sdkErrors.SDKError: Always returns nil
func (s *Store) DeletePolicy(_ context.Context, _ string) *sdkErrors.SDKError {
return nil
}
// GetCipher returns the cipher used for encryption/decryption.
//
// This is a no-op implementation that always returns nil. It exists to
// satisfy the backend interface but provides no cipher since no actual
// encryption operations are performed.
//
// Returns:
// - cipher.AEAD: Always returns nil
func (s *Store) GetCipher() cipher.AEAD {
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package sqlite
import (
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/persist"
)
// New creates a new DataStore instance with the provided configuration.
// It validates the encryption key and initializes the AES-GCM cipher.
//
// The encryption key must be exactly 32 bytes in length (AES-256).
//
// Parameters:
// - cfg: The backend configuration containing encryption key and options
//
// Returns:
// - backend.Backend: The initialized SQLite backend on success
// - *sdkErrors.SDKError: An error if initialization fails
//
// Errors returned:
// - ErrStoreInvalidConfiguration: If options are invalid or key is
// malformed
// - ErrCryptoInvalidEncryptionKeyLength: If key is not 32 bytes
// - ErrCryptoFailedToCreateCipher: If AES cipher creation fails
// - ErrCryptoFailedToCreateGCM: If GCM mode initialization fails
func New(cfg backend.Config) (backend.Backend, *sdkErrors.SDKError) {
opts, err := persist.ParseOptions(cfg.Options)
if err != nil {
failErr := sdkErrors.ErrStoreInvalidConfiguration.Wrap(err)
return nil, failErr
}
key, decodeErr := hex.DecodeString(cfg.EncryptionKey)
if decodeErr != nil {
failErr := sdkErrors.ErrStoreInvalidConfiguration.Wrap(decodeErr)
failErr.Msg = "invalid encryption key"
return nil, failErr
}
// Validate key length
if len(key) != crypto.AES256KeySize {
failErr := *sdkErrors.ErrCryptoInvalidEncryptionKeyLength.Clone()
failErr.Msg = "encryption key must be exactly 32 bytes"
return nil, &failErr
}
block, aesErr := aes.NewCipher(key)
if aesErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateCipher.Wrap(aesErr)
failErr.Msg = "failed to create AES cipher"
return nil, failErr
}
gcm, gcmErr := cipher.NewGCM(block)
if gcmErr != nil {
failErr := sdkErrors.ErrCryptoFailedToCreateGCM.Wrap(gcmErr)
failErr.Msg = "failed to create GCM mode"
return nil, failErr
}
return &persist.DataStore{
Cipher: gcm,
Opts: opts,
}, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import "crypto/cipher"
// GetCipher retrieves the AEAD cipher instance used for encrypting and
// decrypting secrets stored in the database. The cipher is initialized when
// the DataStore is created and remains constant throughout its lifetime.
//
// Returns:
// - cipher.AEAD: The authenticated encryption with associated data cipher
// instance used for secret encryption and decryption operations.
func (s *DataStore) GetCipher() cipher.AEAD {
return s.Cipher
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"crypto/rand"
"io"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// encrypt encrypts the given data using the DataStore's cipher.
// It generates a random nonce for each encryption operation to ensure
// uniqueness.
//
// Parameters:
// - data: The plaintext data to encrypt
//
// Returns:
// - []byte: The encrypted ciphertext
// - []byte: The generated nonce used for encryption
// - *sdkErrors.SDKError: nil on success, or
// sdkErrors.ErrCryptoNonceGenerationFailed if nonce generation fails
func (s *DataStore) encrypt(data []byte) ([]byte, []byte, *sdkErrors.SDKError) {
nonce := make([]byte, s.Cipher.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
failErr := sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(err)
return nil, nil, failErr
}
ciphertext := s.Cipher.Seal(nil, nonce, data, nil)
return ciphertext, nonce, nil
}
// decrypt decrypts the given ciphertext using the DataStore's cipher
// and the provided nonce.
//
// Parameters:
// - ciphertext: The encrypted data to decrypt
// - nonce: The nonce that was used during encryption
//
// Returns:
// - []byte: The decrypted plaintext data
// - *sdkErrors.SDKError: nil on success, or
// sdkErrors.ErrCryptoDecryptionFailed if decryption fails
func (s *DataStore) decrypt(
ciphertext, nonce []byte,
) ([]byte, *sdkErrors.SDKError) {
plaintext, err := s.Cipher.Open(nil, nonce, ciphertext, nil)
if err != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(err)
return nil, failErr
}
return plaintext, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"fmt"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
const (
nonceFieldSecretMetadataCurrentVersion string = "secret_metadata.current_version"
nonceFieldSecretMetadataOldestVersion string = "secret_metadata.oldest_version"
nonceFieldSecretMetadataCreatedTime string = "secret_metadata.created_time"
nonceFieldSecretMetadataUpdatedTime string = "secret_metadata.updated_time"
nonceFieldSecretMetadataMaxVersions string = "secret_metadata.max_versions"
)
// fieldNonceSalts are fixed per-field salts (must match AES-GCM nonce size).
// A per-field nonce is derived as baseNonce XOR salt.
var fieldNonceSalts = map[string][]byte{
nonceFieldSecretMetadataCurrentVersion: []byte("current_ver_"), // 12 bytes
nonceFieldSecretMetadataOldestVersion: []byte("oldest_ver__"), // 12 bytes
nonceFieldSecretMetadataCreatedTime: []byte("created_tim_"), // 12 bytes
nonceFieldSecretMetadataUpdatedTime: []byte("updated_tim_"), // 12 bytes
nonceFieldSecretMetadataMaxVersions: []byte("max_versions"), // 12 bytes
}
// deriveFieldNonce derives a per-field AES-GCM nonce from a base nonce by
// XOR'ing it with a fixed, field-specific salt. This enables using a single
// per-row base nonce while still ensuring each encrypted field uses a distinct
// nonce.
//
// Parameters:
// - baseNonce: The base nonce to derive from. Its length must match the
// cipher's required nonce size and the salt length for the given field.
// - field: The field identifier used to select the derivation salt.
//
// Returns:
// - []byte: The derived nonce for the given field.
// - *sdkErrors.SDKError: An error if the field is unknown
// (ErrEntityInvalid) or if the nonce size does not match
// (ErrCryptoNonceSizeMismatch). Returns nil on success.
func deriveFieldNonce(baseNonce []byte, field string) ([]byte, *sdkErrors.SDKError) {
salt, ok := fieldNonceSalts[field]
if !ok {
failErr := *sdkErrors.ErrEntityInvalid.Clone()
failErr.Msg = fmt.Sprintf("unknown nonce derivation field: %q", field)
return nil, &failErr
}
if len(baseNonce) != len(salt) {
failErr := *sdkErrors.ErrCryptoNonceSizeMismatch.Clone()
failErr.Msg = fmt.Sprintf(
"invalid nonce size for field %q: got %d, want %d",
field, len(baseNonce), len(salt),
)
return nil, &failErr
}
derived := make([]byte, len(baseNonce))
for i := range baseNonce {
derived[i] = baseNonce[i] ^ salt[i]
}
return derived, nil
}
// encryptWithDerivedNonce encrypts data using a nonce derived from the
// provided base nonce and field identifier. The derived nonce is produced by
// applying the field-specific XOR salt in deriveFieldNonce.
//
// Parameters:
// - s: The DataStore containing the AES-GCM cipher for encryption.
// - baseNonce: The base nonce used to derive the per-field nonce.
// - field: The field identifier used to select the derivation salt.
// - data: The plaintext data to encrypt.
//
// Returns:
// - []byte: The encrypted ciphertext.
// - *sdkErrors.SDKError: An error if nonce derivation fails or if encryption
// fails. Returns nil on success.
func encryptWithDerivedNonce(
s *DataStore, baseNonce []byte, field string, data []byte,
) ([]byte, *sdkErrors.SDKError) {
derivedNonce, deriveErr := deriveFieldNonce(baseNonce, field)
if deriveErr != nil {
return nil, deriveErr
}
return encryptWithNonce(s, derivedNonce, data)
}
// decryptWithDerivedNonce decrypts ciphertext using a nonce derived from the
// provided base nonce and field identifier. The derived nonce is produced by
// applying the field-specific XOR salt in deriveFieldNonce.
//
// Parameters:
// - s: The DataStore containing the AES-GCM cipher for decryption.
// - baseNonce: The base nonce used to derive the per-field nonce.
// - field: The field identifier used to select the derivation salt.
// - ciphertext: The encrypted data to decrypt.
//
// Returns:
// - []byte: The decrypted plaintext.
// - *sdkErrors.SDKError: An error if nonce derivation fails or if decryption
// fails. Returns nil on success.
func decryptWithDerivedNonce(
s *DataStore, baseNonce []byte, field string, ciphertext []byte,
) ([]byte, *sdkErrors.SDKError) {
derivedNonce, deriveErr := deriveFieldNonce(baseNonce, field)
if deriveErr != nil {
return nil, deriveErr
}
return s.decrypt(ciphertext, derivedNonce)
}
// \\ 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"
"fmt"
"path/filepath"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
)
// Initialize prepares the DataStore for use by creating the data directory,
// opening the SQLite database connection, configuring connection pool
// settings, and creating required database tables.
//
// The initialization process follows these steps:
// - Validates that the backend is not already initialized
// - Creates the data directory if it does not exist
// - Opens a SQLite database connection with the configured journal mode
// and busy timeout
// - Configures connection pool settings (max open/idle connections and
// connection lifetime)
// - Creates database tables unless SPIKE_DATABASE_SKIP_SCHEMA_CREATION
// is set
//
// Parameters:
// - ctx: Context for managing request lifetime and cancellation.
//
// Returns:
// - *sdkErrors.SDKError: An error if the backend is already initialized,
// the data directory creation fails, the database connection fails, or
// table creation fails. Returns nil on success.
//
// This method is thread-safe and uses a mutex to prevent concurrent
// initialization attempts.
func (s *DataStore) Initialize(ctx context.Context) *sdkErrors.SDKError {
const fName = "Initialize"
validation.NonNilContextOrDie(ctx, fName)
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return sdkErrors.ErrStateAlreadyInitialized
}
if err := s.createDataDir(); err != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(err)
return failErr
}
dbPath := filepath.Join(s.Opts.DataDir, s.Opts.DatabaseFile)
// We don't need a username/password for SQLite.
// Access to SQLite is controlled by regular filesystem permissions.
db, err := sql.Open(
"sqlite3",
fmt.Sprintf("%s?_journal_mode=%s&_busy_timeout=%d",
dbPath, s.Opts.JournalMode, s.Opts.BusyTimeoutMs),
)
if err != nil {
failErr := sdkErrors.ErrFSFileOpenFailed.Wrap(err)
return failErr
}
// Set connection pool settings
db.SetMaxOpenConns(s.Opts.MaxOpenConns)
db.SetMaxIdleConns(s.Opts.MaxIdleConns)
db.SetConnMaxLifetime(s.Opts.ConnMaxLifetime)
// Use the existing database if the schema is not to be created.
if env.DatabaseSkipSchemaCreationVal() {
s.db = db
return nil
}
// Create tables
if createErr := s.createTables(ctx, db); createErr != nil {
closeErr := db.Close()
if closeErr != nil {
return createErr.Wrap(closeErr)
}
return createErr
}
s.db = db
return nil
}
// Close safely closes the database connection. It ensures the database is
// closed only once, even if called multiple times, by using sync.Once.
//
// Parameters:
// - ctx: Context parameter (currently unused but maintained for interface
// compatibility).
//
// Returns:
// - *sdkErrors.SDKError: An error if closing the database connection
// fails, wrapped in ErrFSFileCloseFailed. Returns nil on success.
// Later calls always return nil since the close operation only
// executes once.
//
// This method is thread-safe.
func (s *DataStore) Close(_ context.Context) *sdkErrors.SDKError {
var err error
s.closeOnce.Do(func() {
err = s.db.Close()
})
if err != nil {
return sdkErrors.ErrStoreCloseFailed.Wrap(err)
}
return nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"crypto/rand"
"fmt"
"io"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// generateNonce generates a cryptographically secure random nonce for use
// with AES-GCM encryption. The nonce size is determined by the cipher's
// requirements (typically 12 bytes for AES-GCM).
//
// If nonce generation fails, this function terminates the program via
// log.FatalErr, as this indicates a critical cryptographic system failure.
//
// Parameters:
// - s: The DataStore containing the cipher whose nonce size will be used
//
// Returns:
// - []byte: A cryptographically secure random nonce of the required size
// - *sdkErrors.SDKError: Always returns nil (function terminates on error)
func generateNonce(s *DataStore) ([]byte, *sdkErrors.SDKError) {
const fName = "generateNonce"
nonce := make([]byte, s.Cipher.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
failErr := sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(err)
log.FatalErr(fName, *failErr)
}
return nonce, nil
}
// encryptWithNonce encrypts data using AES-GCM with the provided nonce.
// The function validates that the nonce size matches the cipher's
// requirements before performing encryption.
//
// Parameters:
// - s: The DataStore containing the AES-GCM cipher for encryption
// - nonce: The nonce to use for encryption (must match cipher's nonce size)
// - data: The plaintext data to encrypt
//
// Returns:
// - []byte: The encrypted ciphertext, or nil if an error occurs
// - *sdkErrors.SDKError: nil on success, or ErrCryptoNonceSizeMismatch
// if the nonce size does not match the cipher's requirements
func encryptWithNonce(
s *DataStore, nonce []byte, data []byte,
) ([]byte, *sdkErrors.SDKError) {
if len(nonce) != s.Cipher.NonceSize() {
failErr := *sdkErrors.ErrCryptoNonceSizeMismatch.Clone()
failErr.Msg = fmt.Sprintf(
"invalid nonce size: got %d, want %d",
len(nonce), s.Cipher.NonceSize(),
)
return nil, &failErr
}
ciphertext := s.Cipher.Seal(nil, nonce, data, nil)
return ciphertext, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"time"
"github.com/spiffe/spike-sdk-go/config/env"
)
// Options defines SQLite-specific configuration options
type Options struct {
// DataDir specifies the directory where the SQLite database file
// will be stored
DataDir string
// DatabaseFile specifies the name of the SQLite database file
DatabaseFile string
// JournalMode specifies the SQLite journal mode
// (DELETE, WAL, MEMORY, etc.)
JournalMode string
// BusyTimeoutMs specifies the busy timeout in milliseconds
BusyTimeoutMs int
// MaxOpenConns specifies the maximum number of open connections
MaxOpenConns int
// MaxIdleConns specifies the maximum number of idle connections
MaxIdleConns int
// ConnMaxLifetime specifies the maximum amount of time
// a connection may be reused
ConnMaxLifetime time.Duration
}
const spikeDataFolderName = ".spike"
const spikeDBName = "spike.db"
// DefaultOptions returns the default SQLite options
func DefaultOptions() *Options {
return &Options{
DataDir: spikeDataFolderName,
DatabaseFile: spikeDBName,
JournalMode: env.DatabaseJournalModeVal(),
BusyTimeoutMs: env.DatabaseBusyTimeoutMsVal(),
MaxOpenConns: env.DatabaseMaxOpenConnsVal(),
MaxIdleConns: env.DatabaseMaxIdleConnsVal(),
ConnMaxLifetime: env.DatabaseConnMaxLifetimeSecVal(),
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"fmt"
"time"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
)
// ParseOptions parses and validates database configuration options for
// SQLite persistence. It extracts configuration values from the provided
// options map, applies defaults for any missing or zero values, and validates
// the resulting configuration.
//
// Parameters:
// - opts: A map of database configuration keys to values. If nil, the
// function returns default options without error. Supported keys include
// DataDir, DatabaseFile, JournalMode, BusyTimeoutMs, MaxOpenConns,
// MaxIdleConns, and ConnMaxLifetimeSeconds.
//
// Returns:
// - *Options: A fully populated Options struct with validated settings.
// Default values are applied for any missing or zero-valued fields.
// - *sdkErrors.SDKError: An error if validation fails. Currently, the only
// validation enforced is that MaxIdleConns must not exceed MaxOpenConns.
// Returns nil on success.
func ParseOptions(opts map[backend.DatabaseConfigKey]any) (
*Options, *sdkErrors.SDKError,
) {
if opts == nil {
return DefaultOptions(), nil
}
sqliteOpts := &Options{}
// Parse each field from the map
if dataDir, ok := opts[backend.KeyDataDir].(string); ok {
sqliteOpts.DataDir = dataDir
}
if dbFile, ok := opts[backend.KeyDatabaseFile].(string); ok {
sqliteOpts.DatabaseFile = dbFile
}
if journalMode, ok := opts[backend.KeyJournalMode].(string); ok {
sqliteOpts.JournalMode = journalMode
}
if busyTimeout, ok := opts[backend.KeyBusyTimeoutMs].(int); ok {
sqliteOpts.BusyTimeoutMs = busyTimeout
}
if maxOpen, ok := opts[backend.KeyMaxOpenConns].(int); ok {
sqliteOpts.MaxOpenConns = maxOpen
}
if maxIdle, ok := opts[backend.KeyMaxIdleConns].(int); ok {
sqliteOpts.MaxIdleConns = maxIdle
}
if lifetime, ok := opts[backend.KeyConnMaxLifetimeSeconds].(time.Duration); ok {
sqliteOpts.ConnMaxLifetime = lifetime
}
// Apply defaults for zero values
if sqliteOpts.DataDir == "" {
sqliteOpts.DataDir = DefaultOptions().DataDir
}
if sqliteOpts.DatabaseFile == "" {
sqliteOpts.DatabaseFile = DefaultOptions().DatabaseFile
}
if sqliteOpts.JournalMode == "" {
sqliteOpts.JournalMode = DefaultOptions().JournalMode
}
if sqliteOpts.BusyTimeoutMs == 0 {
sqliteOpts.BusyTimeoutMs = DefaultOptions().BusyTimeoutMs
}
if sqliteOpts.MaxOpenConns == 0 {
sqliteOpts.MaxOpenConns = DefaultOptions().MaxOpenConns
}
if sqliteOpts.MaxIdleConns == 0 {
sqliteOpts.MaxIdleConns = DefaultOptions().MaxIdleConns
}
if sqliteOpts.ConnMaxLifetime == 0 {
sqliteOpts.ConnMaxLifetime = DefaultOptions().ConnMaxLifetime
}
// Validate options
if sqliteOpts.MaxIdleConns > sqliteOpts.MaxOpenConns {
failErr := *sdkErrors.ErrStoreInvalidConfiguration.Clone()
failErr.Msg = fmt.Sprintf(
"MaxIdleConns (%d) cannot be greater than MaxOpenConns (%d)",
sqliteOpts.MaxIdleConns, sqliteOpts.MaxOpenConns,
)
return nil, &failErr
}
return sqliteOpts, nil
}
// \\ 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"
"errors"
"fmt"
"strings"
"time"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
// DeletePolicy removes a policy from the database by its ID.
//
// Uses serializable transaction isolation to ensure consistency.
// Automatically rolls back on error.
//
// Parameters:
// - ctx: Context for the database operation
// - id: Unique identifier of the policy to delete
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or an error if transaction
// operations fail or policy deletion fails
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)
}
}
}(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
}
// StorePolicy saves or updates a policy in the database.
//
// Uses serializable transaction isolation to ensure consistency.
// Automatically rolls back on error.
//
// Parameters:
// - ctx: Context for the database operation
// - policy: Policy data to store, containing ID, name, patterns, and creation
// time
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or an error if transaction
// operations fail, encryption fails, or policy storage fails
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 {
permissions := make([]string, len(policy.Permissions))
for i, perm := range policy.Permissions {
permissions[i] = string(perm)
}
permissionsStr = strings.Join(permissions, ",")
}
// Encryption
nonce, nonceErr := generateNonce(s)
if nonceErr != nil {
return sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(nonceErr)
}
encryptedSpiffeID, encErr := encryptWithNonce(
s, nonce, []byte(policy.SPIFFEIDPattern),
)
if encErr != nil {
failErr := sdkErrors.ErrCryptoEncryptionFailed.Wrap(encErr)
failErr.Msg = fmt.Sprintf(
"failed to encrypt SPIFFE ID pattern for policy %s", policy.ID,
)
return failErr
}
encryptedPathPattern, pathErr := encryptWithNonce(
s, nonce, []byte(policy.PathPattern),
)
if pathErr != nil {
failErr := sdkErrors.ErrCryptoEncryptionFailed.Wrap(pathErr)
failErr.Msg = fmt.Sprintf(
"failed to encrypt path pattern for policy %s", policy.ID,
)
return failErr
}
encryptedPermissions, permErr := encryptWithNonce(
s, nonce, []byte(permissionsStr),
)
if permErr != nil {
failErr := sdkErrors.ErrCryptoEncryptionFailed.Wrap(permErr)
failErr.Msg = fmt.Sprintf(
"failed to encrypt permissions for policy %s", policy.ID,
)
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
}
// LoadPolicy retrieves a policy from the database and compiles its patterns.
//
// Parameters:
// - ctx: Context for the database operation
// - id: Unique identifier of the policy to load
//
// Returns:
// - *data.Policy: Loaded policy with compiled patterns, nil if not found or
// if an error occurs
// - *sdkErrors.SDKError: nil on success, sdkErrors.ErrEntityNotFound if the
// policy does not exist, or an error if database operations fail,
// decryption fails, or pattern compilation fails
func (s *DataStore) LoadPolicy(
ctx context.Context, id string,
) (*data.Policy, *sdkErrors.SDKError) {
const fName = "LoadPolicy"
validation.NonNilContextOrDie(ctx, fName)
s.mu.RLock()
defer s.mu.RUnlock()
var policy data.Policy
var encryptedSPIFFEIDPattern []byte
var encryptedPathPattern []byte
var encryptedPermissions []byte
var nonce []byte
var createdTime int64
var updatedTime int64
scanErr := s.db.QueryRowContext(ctx, ddl.QueryLoadPolicy, id).Scan(
&policy.ID,
&policy.Name,
&encryptedSPIFFEIDPattern,
&encryptedPathPattern,
&encryptedPermissions,
&nonce,
&createdTime,
&updatedTime,
)
if scanErr != nil {
if errors.Is(scanErr, sql.ErrNoRows) {
return nil, sdkErrors.ErrEntityNotFound
}
failErr := sdkErrors.ErrEntityLoadFailed.Wrap(scanErr)
return nil, failErr
}
// Decrypt
decryptedSPIFFEIDPattern, spiffeDecryptErr := s.decrypt(
encryptedSPIFFEIDPattern, nonce,
)
if spiffeDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(spiffeDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt SPIFFE ID pattern for policy %s", policy.ID,
)
return nil, failErr
}
decryptedPathPattern, pathDecryptErr := s.decrypt(encryptedPathPattern, nonce)
if pathDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(pathDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt path pattern for policy %s", policy.ID,
)
return nil, failErr
}
decryptedPermissions, permDecryptErr := s.decrypt(encryptedPermissions, nonce)
if permDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(permDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt permissions for policy %s", policy.ID,
)
return nil, failErr
}
// Set decrypted values
policy.SPIFFEIDPattern = string(decryptedSPIFFEIDPattern)
policy.PathPattern = string(decryptedPathPattern)
policy.CreatedAt = time.Unix(createdTime, 0)
policy.UpdatedAt = time.Unix(updatedTime, 0)
policy.Permissions = deserializePermissions(string(decryptedPermissions))
// Compile regex
if compileErr := compileRegexPatterns(&policy); compileErr != nil {
return nil, compileErr
}
return &policy, nil
}
// LoadAllPolicies retrieves all policies from the backend storage.
//
// The function loads all policy data and compiles regex patterns for SPIFFE ID
// and path matching. If any individual policy fails to load, decrypt, or
// compile (due to corruption or invalid data), the error is logged as a
// warning and that policy is skipped. This allows the system to continue
// operating with valid policies even when some policies are corrupted.
//
// Parameters:
// - ctx: Context for the database operation
//
// Returns:
// - map[string]*data.Policy: Map of policy IDs to successfully loaded
// policies with compiled patterns. May be incomplete if some policies
// failed to load (check logs for warnings).
// - *sdkErrors.SDKError: nil on success, or an error if the database query
// itself fails or if iterating over rows fails. Individual policy load
// failures do not cause the function to return an error.
func (s *DataStore) LoadAllPolicies(
ctx context.Context,
) (map[string]*data.Policy, *sdkErrors.SDKError) {
const fName = "LoadAllPolicies"
validation.NonNilContextOrDie(ctx, fName)
s.mu.RLock()
defer s.mu.RUnlock()
rows, queryErr := s.db.QueryContext(ctx, ddl.QueryAllPolicies)
if queryErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(queryErr)
}
defer func(rows *sql.Rows) {
closeErr := rows.Close()
if closeErr != nil {
failErr := sdkErrors.ErrFSFileCloseFailed.Wrap(closeErr)
failErr.Msg = "failed to close rows"
log.WarnErr(fName, *failErr)
}
}(rows)
policies := make(map[string]*data.Policy)
for rows.Next() {
var policy data.Policy
var encryptedSPIFFEIDPattern []byte
var encryptedPathPattern []byte
var encryptedPermissions []byte
var nonce []byte
var createdTime int64
var updatedTime int64
if scanErr := rows.Scan(
&policy.ID,
&policy.Name,
&encryptedSPIFFEIDPattern,
&encryptedPathPattern,
&encryptedPermissions,
&nonce,
&createdTime,
&updatedTime,
); scanErr != nil {
failErr := sdkErrors.ErrEntityQueryFailed.Wrap(scanErr)
failErr.Msg = "failed to scan policy row, skipping"
log.WarnErr(fName, *failErr)
continue
}
// Decrypt
decryptedSPIFFEIDPattern, spiffeDecryptErr := s.decrypt(
encryptedSPIFFEIDPattern, nonce,
)
if spiffeDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(spiffeDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt SPIFFE ID pattern for policy %s, skipping",
policy.ID,
)
log.WarnErr(fName, *failErr)
continue
}
decryptedPathPattern, pathDecryptErr := s.decrypt(
encryptedPathPattern, nonce,
)
if pathDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(pathDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt path pattern for policy %s, skipping",
policy.ID,
)
log.WarnErr(fName, *failErr)
continue
}
decryptedPermissions, permDecryptErr := s.decrypt(
encryptedPermissions, nonce,
)
if permDecryptErr != nil {
failErr := sdkErrors.ErrCryptoDecryptionFailed.Wrap(permDecryptErr)
failErr.Msg = fmt.Sprintf(
"failed to decrypt permissions for policy %s, skipping",
policy.ID,
)
log.WarnErr(fName, *failErr)
continue
}
policy.SPIFFEIDPattern = string(decryptedSPIFFEIDPattern)
policy.PathPattern = string(decryptedPathPattern)
policy.CreatedAt = time.Unix(createdTime, 0)
policy.UpdatedAt = time.Unix(updatedTime, 0)
policy.Permissions = deserializePermissions(
string(decryptedPermissions),
)
// Compile regex
if compileErr := compileRegexPatterns(&policy); compileErr != nil {
failErr := sdkErrors.ErrEntityInvalid.Wrap(compileErr)
failErr.Msg = fmt.Sprintf(
"failed to compile regex patterns for policy %s, skipping",
policy.ID,
)
log.WarnErr(fName, *failErr)
continue
}
policies[policy.ID] = &policy
}
if rowsErr := rows.Err(); rowsErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(rowsErr)
}
return policies, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"regexp"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// compileRegexPatterns compiles the SPIFFE ID and path patterns from the
// policy into regular expressions, storing them in the policy's IDRegex and
// PathRegex fields. This function modifies the policy in place.
//
// Parameters:
// - policy: The policy containing SPIFFEIDPattern and PathPattern strings
// to compile. The compiled regexes are stored in the IDRegex and
// PathRegex fields.
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or ErrEntityInvalid if either
// pattern fails to compile as a valid regular expression
func compileRegexPatterns(
policy *data.Policy,
) *sdkErrors.SDKError {
var err error
policy.IDRegex, err = regexp.Compile(policy.SPIFFEIDPattern)
if err != nil {
failErr := sdkErrors.ErrEntityInvalid.Wrap(err)
failErr.Msg = "invalid SPIFFE ID pattern " + policy.SPIFFEIDPattern
return failErr
}
policy.PathRegex, err = regexp.Compile(policy.PathPattern)
if err != nil {
failErr := sdkErrors.ErrEntityInvalid.Wrap(err)
failErr.Msg = "invalid path pattern " + policy.PathPattern
return failErr
}
return nil
}
// \\ 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"
"os"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
"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.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - db: The SQLite database connection on which to create the tables
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or ErrEntityQueryFailed if the
// schema creation fails
func (s *DataStore) createTables(
ctx context.Context, db *sql.DB,
) *sdkErrors.SDKError {
const fName = "createTables"
validation.NonNilContextOrDie(ctx, fName)
_, err := db.ExecContext(ctx, ddl.QueryInitialize)
if err != nil {
return sdkErrors.ErrEntityQueryFailed.Wrap(err)
}
return nil
}
// \\ 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"
"encoding/json"
"strconv"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/kv"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
// StoreSecret stores a secret at the specified path with its metadata and
// versions. It performs the following operations atomically within a
// transaction:
// - Encrypts and updates the secret metadata (current version, creation time, update time)
// - Stores all secret versions with their respective data encrypted using
// AES-GCM
//
// The secret data is JSON-encoded before encryption. This method is
// thread-safe.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - path: The secret path where the secret will be stored
// - secret: The secret value containing metadata and versions to store
//
// Returns:
// - *sdkErrors.SDKError: nil on success, or one of the following errors:
// - ErrTransactionBeginFailed: If the transaction fails to begin
// - ErrEntityQueryFailed: If database operations fail
// - ErrDataMarshalFailure: If data marshaling fails
// - ErrCryptoEncryptionFailed: If encryption fails
// - ErrTransactionCommitFailed: If the transaction fails to commit
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)
nonce, nonceErr := generateNonce(s)
if nonceErr != nil {
return sdkErrors.ErrCryptoNonceGenerationFailed.Wrap(nonceErr)
}
// time.Time → []byte (Unix seconds as string)
createdBytes := []byte(strconv.FormatInt(secret.Metadata.CreatedTime.Unix(), 10))
updatedBytes := []byte(strconv.FormatInt(secret.Metadata.UpdatedTime.Unix(), 10))
// int → []byte (decimal string)
currentVersionBytes := []byte(strconv.Itoa(secret.Metadata.CurrentVersion))
oldestVersionBytes := []byte(strconv.Itoa(secret.Metadata.OldestVersion))
maxVersionsBytes := []byte(strconv.Itoa(secret.Metadata.MaxVersions))
// Encrypt metadata using a derived per-field nonce.
encryptedCurrentVersion, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCurrentVersion, currentVersionBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedOldestVersion, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataOldestVersion, oldestVersionBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedMaxVersions, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataMaxVersions, maxVersionsBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedCreatedTime, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCreatedTime, createdBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
encryptedUpdatedTime, encryptErr := encryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataUpdatedTime, updatedBytes,
)
if encryptErr != nil {
return sdkErrors.ErrCryptoEncryptionFailed.Wrap(encryptErr)
}
// Update metadata
_, err = tx.ExecContext(ctx, ddl.QueryUpdateSecretMetadata,
path, nonce, encryptedCurrentVersion, encryptedOldestVersion,
encryptedCreatedTime,
encryptedUpdatedTime, encryptedMaxVersions,
)
if err != nil {
return sdkErrors.ErrEntityQueryFailed.Wrap(err)
}
// Update versions
for version, sv := range secret.Versions {
md, marshalErr := json.Marshal(sv.Data)
if marshalErr != nil {
return sdkErrors.ErrDataMarshalFailure.Wrap(marshalErr)
}
encrypted, nonce, encryptErr := s.encrypt(md)
if encryptErr != nil {
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)
}
}
if err := tx.Commit(); err != nil {
return sdkErrors.ErrTransactionCommitFailed.Wrap(err)
}
committed = true
return nil
}
// LoadSecret retrieves a secret and all its versions from the specified path.
// It performs the following operations:
// - Loads the secret metadata
// - Retrieves all secret versions
// - Decrypts and unmarshals the version data
//
// This method is thread-safe.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - path: The secret path to load
//
// Returns:
// - *kv.Value: The decrypted secret with all its versions, or nil if the
// secret doesn't exist
// - *sdkErrors.SDKError: nil on success, or one of the following errors:
// - ErrEntityLoadFailed: If loading secret metadata fails
// - ErrEntityQueryFailed: If querying versions fails
// - ErrCryptoDecryptionFailed: If decrypting a version fails
// - ErrDataUnmarshalFailure: If unmarshaling JSON data fails
func (s *DataStore) LoadSecret(
ctx context.Context, path string,
) (*kv.Value, *sdkErrors.SDKError) {
const fName = "LoadSecret"
validation.NonNilContextOrDie(ctx, fName)
s.mu.RLock()
defer s.mu.RUnlock()
return s.loadSecretInternal(ctx, path)
}
// LoadAllSecrets retrieves all secrets from the database. It returns a map
// where the keys are secret paths and the values are the corresponding
// secrets. Each secret includes its metadata and all versions with decrypted
// data. This method is thread-safe.
//
// If any individual secret fails to load or decrypt (due to corruption or
// invalid data), the error is logged as a warning and that secret is skipped.
// This allows the system to continue operating with valid secrets even when
// some secrets are corrupted.
//
// Contexts that are canceled or reach their deadline will result in the
// operation being interrupted early and returning an error.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
//
// Returns:
// - map[string]*kv.Value: A map of secret paths to their corresponding
// secret values. May be incomplete if some secrets failed to load (check
// logs for warnings).
// - *sdkErrors.SDKError: nil on success, or an error if the database query
// itself fails or if iterating over rows fails. Individual secret load
// failures do not cause the function to return an error.
//
// Example usage:
//
// secrets, err := dataStore.LoadAllSecrets(context.Background())
// if err != nil {
// log.Fatalf("Failed to load secrets: %v", err)
// }
// for path, secret := range secrets {
// fmt.Printf("Secret at path %s has %d versions\n", path,
// len(secret.Versions))
// }
func (s *DataStore) LoadAllSecrets(
ctx context.Context,
) (map[string]*kv.Value, *sdkErrors.SDKError) {
fName := "LoadAllSecrets"
validation.NonNilContextOrDie(ctx, fName)
s.mu.RLock()
defer s.mu.RUnlock()
// Get all secret paths
rows, err := s.db.QueryContext(ctx, ddl.QueryPathsFromMetadata)
if err != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(err)
}
defer func(rows *sql.Rows) {
closeErr := rows.Close()
if closeErr != nil {
failErr := *sdkErrors.ErrFSFileCloseFailed.Wrap(closeErr)
log.WarnErr(fName, failErr)
}
}(rows)
// Map to store all secrets
secrets := make(map[string]*kv.Value)
// Iterate over paths
for rows.Next() {
var path string
if scanErr := rows.Scan(&path); scanErr != nil {
failErr := sdkErrors.ErrEntityQueryFailed.Wrap(scanErr)
failErr.Msg = "failed to scan secret path row, skipping"
log.WarnErr(fName, *failErr)
continue
}
// Load the full secret for this path
secret, loadErr := s.loadSecretInternal(ctx, path)
if loadErr != nil {
failErr := sdkErrors.ErrEntityLoadFailed.Wrap(loadErr)
failErr.Msg = "failed to load secret at path " + path + ", skipping"
log.WarnErr(fName, *failErr)
continue
}
if secret != nil {
secrets[path] = secret
}
}
if rowErr := rows.Err(); rowErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(rowErr)
}
return secrets, nil
}
// \\ 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"
"encoding/json"
"errors"
"strconv"
"time"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/kv"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
// loadSecretInternal retrieves a secret and all its versions from the database
// for the specified path. It performs the actual database operations including
// loading and decrypting metadata, fetching all versions, and decrypting the secret data.
//
// The function first queries and decrypts secret metadata (current version, timestamps),
// then retrieves all versions of the secret, decrypts each version, and
// reconstructs the complete secret structure.
//
// Parameters:
// - ctx: Context for cancellation and timeout control
// - path: The secret path to load
//
// Returns:
// - *kv.Value: The complete secret with all versions and metadata
// - *sdkErrors.SDKError: An error if the secret is not found or any database
// or decryption operation fails. Returns nil on success.
//
// Possible errors:
// - ErrEntityNotFound: If the secret does not exist at the specified path
// - ErrEntityLoadFailed: If loading secret metadata fails
// - ErrEntityQueryFailed: If querying versions fails or rows.Scan fails
// - ErrCryptoDecryptionFailed: If decrypting a version fails
// - ErrDataUnmarshalFailure: If unmarshaling JSON data fails
//
// Special behavior:
// - Automatically handles deleted versions by setting DeletedTime when
// present
//
// The function handles the following operations:
// 1. Queries secret metadata from the secret_metadata table
// 2. Decrypts secret metadata
// 2. Fetches all versions from the secrets table
// 3. Decrypts each version using the DataStore's cipher
// 4. Unmarshals JSON data into a map[string]string format
// 5. Assembles the complete kv.Value structure
func (s *DataStore) loadSecretInternal(
ctx context.Context, path string,
) (*kv.Value, *sdkErrors.SDKError) {
const fName = "loadSecretInternal"
validation.NonNilContextOrDie(ctx, fName)
var secret kv.Value
var (
nonce []byte
encryptedCurrentVersion []byte
encryptedOldestVersion []byte
encryptedCreatedTime []byte
encryptedUpdatedTime []byte
encryptedMaxVersions []byte
)
// Load metadata
metaErr := s.db.QueryRowContext(ctx, ddl.QuerySecretMetadata, path).Scan(
&nonce,
&encryptedCurrentVersion,
&encryptedOldestVersion,
&encryptedCreatedTime,
&encryptedUpdatedTime,
&encryptedMaxVersions,
)
if metaErr != nil {
if errors.Is(metaErr, sql.ErrNoRows) {
return nil, sdkErrors.ErrEntityNotFound
}
return nil, sdkErrors.ErrEntityLoadFailed
}
// Decrypt metadata using per-field derived nonces.
currentVersionBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCurrentVersion, encryptedCurrentVersion,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
oldestVersionBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataOldestVersion, encryptedOldestVersion,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
createdBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataCreatedTime, encryptedCreatedTime,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
updatedBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataUpdatedTime, encryptedUpdatedTime,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
maxVersionsBytes, decryptErr := decryptWithDerivedNonce(
s, nonce, nonceFieldSecretMetadataMaxVersions, encryptedMaxVersions,
)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
// Decode into struct
secret.Metadata.CurrentVersion, _ = strconv.Atoi(string(currentVersionBytes))
secret.Metadata.OldestVersion, _ = strconv.Atoi(string(oldestVersionBytes))
createdSec, _ := strconv.ParseInt(string(createdBytes), 10, 64)
updatedSec, _ := strconv.ParseInt(string(updatedBytes), 10, 64)
secret.Metadata.CreatedTime = time.Unix(createdSec, 0)
secret.Metadata.UpdatedTime = time.Unix(updatedSec, 0)
secret.Metadata.MaxVersions, _ = strconv.Atoi(string(maxVersionsBytes))
// Load versions
rows, queryErr := s.db.QueryContext(ctx, ddl.QuerySecretVersions, path)
if queryErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(queryErr)
}
defer func(rows *sql.Rows) {
closeErr := rows.Close()
if closeErr != nil {
failErr := sdkErrors.ErrFSFileCloseFailed.Wrap(closeErr)
log.WarnErr(fName, *failErr)
}
}(rows)
secret.Versions = make(map[int]kv.Version)
for rows.Next() {
var (
version int
nonce []byte
encrypted []byte
createdTime time.Time
deletedTime sql.NullTime
)
if scanErr := rows.Scan(
&version, &nonce,
&encrypted, &createdTime, &deletedTime,
); scanErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(scanErr)
}
decrypted, decryptErr := s.decrypt(encrypted, nonce)
if decryptErr != nil {
return nil, sdkErrors.ErrCryptoDecryptionFailed.Wrap(decryptErr)
}
var values map[string]string
if unmarshalErr := json.Unmarshal(decrypted, &values); unmarshalErr != nil {
return nil, sdkErrors.ErrDataUnmarshalFailure.Wrap(unmarshalErr)
}
sv := kv.Version{
Data: values,
CreatedTime: createdTime,
}
if deletedTime.Valid {
sv.DeletedTime = &deletedTime.Time
}
secret.Versions[version] = sv
}
if rowsErr := rows.Err(); rowsErr != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(rowsErr)
}
// Integrity check: If CurrentVersion is non-zero, it must exist in
// the Versions map. CurrentVersion==0 indicates a "shell secret"
// where all versions are deleted, which is valid.
if secret.Metadata.CurrentVersion != 0 {
if _, exists := secret.Versions[secret.Metadata.CurrentVersion]; !exists {
integrityErr := sdkErrors.ErrStateIntegrityCheck.Clone()
integrityErr.Msg = "data integrity violation: current version not found"
return nil, integrityErr
}
}
return &secret, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/config/fs"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
// TestingInterface allows both *testing.T and *testing.B to be used
type TestingInterface interface {
Fatalf(format string, args ...interface{})
Errorf(format string, args ...interface{})
Logf(format string, args ...interface{})
}
type TestSecretMetadata struct {
CurrentVersion int
OldestVersion int
MaxVersions int
CreatedTime time.Time
UpdatedTime time.Time
}
// Helper functions for SQLite testing
func createTestRootKey(_ TestingInterface) *[crypto.AES256KeySize]byte {
key := &[crypto.AES256KeySize]byte{}
// Use a predictable pattern for testing
for i := range key {
key[i] = byte(i % 256)
}
return key
}
func withSQLiteEnvironment(_ *testing.T, testFunc func()) {
// Save original environment variables
originalStore := os.Getenv(env.NexusBackendStore)
originalSkipSchema := os.Getenv(env.NexusDBSkipSchemaCreation)
// Ensure cleanup happens
defer func() {
if originalStore != "" {
_ = os.Setenv(env.NexusBackendStore, originalStore)
} else {
_ = os.Unsetenv(env.NexusBackendStore)
}
if originalSkipSchema != "" {
_ = os.Setenv(env.NexusDBSkipSchemaCreation, originalSkipSchema)
} else {
_ = os.Unsetenv(env.NexusDBSkipSchemaCreation)
}
}()
// Set to SQLite backend and ensure schema creation
_ = os.Setenv(env.NexusBackendStore, "sqlite")
_ = os.Unsetenv(env.NexusDBSkipSchemaCreation)
// Run the test function
testFunc()
}
func cleanupSQLiteDatabase(t *testing.T) {
dataDir := fs.NexusDataFolder()
dbPath := filepath.Join(dataDir, "spike.db")
// Remove the database file if it exists
if _, err := os.Stat(dbPath); err == nil {
t.Logf("Removing existing database at %s", dbPath)
if err := os.Remove(dbPath); err != nil {
t.Logf("Warning: Failed to remove existing database: %v", err)
}
}
}
func createTestDataStore(t TestingInterface) *DataStore {
rootKey := createTestRootKey(t)
block, cipherErr := aes.NewCipher(rootKey[:])
if cipherErr != nil {
t.Fatalf("Failed to create cipher: %v", cipherErr)
}
gcm, gcmErr := cipher.NewGCM(block)
if gcmErr != nil {
t.Fatalf("Failed to create GCM: %v", gcmErr)
}
// Use DefaultOptions and override the data directory for testing
opts := DefaultOptions()
opts.DataDir = fs.NexusDataFolder()
// Create a unique database filename to avoid race conditions
opts.DatabaseFile = fmt.Sprintf("spike_test_%d.db", time.Now().UnixNano())
store := &DataStore{
Opts: opts,
Cipher: gcm,
}
// Initialize the database
ctx := context.Background()
if initErr := store.Initialize(ctx); initErr != nil {
t.Fatalf("Failed to initialize datastore: %v", initErr)
}
dbPath := filepath.Join(opts.DataDir, opts.DatabaseFile)
t.Logf("Test datastore initialized with database at %s", dbPath)
return store
}
func storeTestSecretDirectly(t TestingInterface, store *DataStore, path string,
versions map[int]map[string]string, metadata TestSecretMetadata) {
ctx := context.Background()
insertEncryptedMetadata(ctx, t, store, path, metadata)
// Insert versions
for version, data := range versions {
// Encrypt the data
jsonData := `{`
first := true
for k, v := range data {
if !first {
jsonData += `,`
}
jsonData += `"` + k + `":"` + v + `"`
first = false
}
jsonData += `}`
nonce := make([]byte, store.Cipher.NonceSize())
if _, randErr := rand.Read(nonce); randErr != nil {
t.Fatalf("Failed to generate nonce: %v", randErr)
}
encrypted := store.Cipher.Seal(nil, nonce, []byte(jsonData), nil)
createdTime := metadata.CreatedTime.Add(time.Duration(version) * time.Hour)
var deletedTime *time.Time
if version == 2 {
// Make version 2 deleted for testing
deleted := metadata.UpdatedTime.Add(-1 * time.Hour)
deletedTime = &deleted
}
_, execErr := store.db.ExecContext(ctx, ddl.QueryUpsertSecret,
path, version, nonce, encrypted, createdTime, deletedTime)
if execErr != nil {
t.Fatalf("Failed to insert version %d: %v", version, execErr)
}
}
}
// insertEncryptedMetadata is a helper function that encrypts the metadata fields with per-field nonces
// and inserts the encrypted values into the secret_metadata table
func insertEncryptedMetadata(ctx context.Context, t TestingInterface, store *DataStore, path string, metadata TestSecretMetadata) {
nonce := make([]byte, store.Cipher.NonceSize())
if _, randErr := rand.Read(nonce); randErr != nil {
t.Fatalf("Failed to generate metadata nonce: %v", randErr)
}
// Encrypt metadata
encryptedCurrentversion, encryptErr := encryptWithDerivedNonce(
store, nonce, nonceFieldSecretMetadataCurrentVersion,
[]byte(strconv.Itoa(metadata.CurrentVersion)),
)
if encryptErr != nil {
t.Fatalf("Failed to encrypt current version: %v", encryptErr)
}
encryptedOldestVersion, encryptErr := encryptWithDerivedNonce(
store, nonce, nonceFieldSecretMetadataOldestVersion,
[]byte(strconv.Itoa(metadata.OldestVersion)),
)
if encryptErr != nil {
t.Fatalf("Failed to encrypt oldest version: %v", encryptErr)
}
encryptedMaxVersions, encryptErr := encryptWithDerivedNonce(
store, nonce, nonceFieldSecretMetadataMaxVersions,
[]byte(strconv.Itoa(metadata.MaxVersions)),
)
if encryptErr != nil {
t.Fatalf("Failed to encrypt max versions: %v", encryptErr)
}
encryptedCreatedTime, encryptErr := encryptWithDerivedNonce(
store, nonce, nonceFieldSecretMetadataCreatedTime,
[]byte(strconv.FormatInt(metadata.CreatedTime.Unix(), 10)),
)
if encryptErr != nil {
t.Fatalf("Failed to encrypt created time: %v", encryptErr)
}
encryptedUpdatedTime, encryptErr := encryptWithDerivedNonce(
store, nonce, nonceFieldSecretMetadataUpdatedTime,
[]byte(strconv.FormatInt(metadata.UpdatedTime.Unix(), 10)),
)
if encryptErr != nil {
t.Fatalf("Failed to encrypt updated time: %v", encryptErr)
}
// Insert metadata
_, execErr := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata,
path, nonce, encryptedCurrentversion, encryptedOldestVersion, encryptedCreatedTime, encryptedUpdatedTime, encryptedMaxVersions,
)
if execErr != nil {
t.Fatalf("Failed to insert metadata: %v", execErr)
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"strings"
"github.com/spiffe/spike-sdk-go/api/entity/data"
)
// deserializePermissions converts a comma-separated string of permissions
// into a slice of PolicyPermission values.
//
// This function is used when loading policies from the SQLite database, where
// permissions are stored as a comma-separated string. Each permission value
// is trimmed of whitespace before being converted to a PolicyPermission type.
//
// Valid permissions are defined in config.ValidPermissions:
// - read: Read access to resources
// - write: Write access to resources
// - list: List access to resources
// - execute: Execute access to resources
// - super: Superuser access (grants all permissions)
//
// Parameters:
// - permissionsStr: A comma-separated string of permission values
// (e.g., "read,write,execute")
//
// Returns:
// - []data.PolicyPermission: A slice of PolicyPermission values, or nil if
// the input string is empty
func deserializePermissions(
permissionsStr string,
) []data.PolicyPermission {
if permissionsStr == "" {
return nil
}
perms := strings.Split(permissionsStr, ",")
permissions := make([]data.PolicyPermission, len(perms))
for i, p := range perms {
permissions[i] = data.PolicyPermission(strings.TrimSpace(p))
}
return permissions
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/log"
)
// RootKeyNoLock returns a copy of the root key without acquiring the lock.
// This should only be used in contexts where the lock is already held
// or thread safety is managed externally.
//
// Returns:
// - *[32]byte: Pointer to the root key
func RootKeyNoLock() *[crypto.AES256KeySize]byte {
return &rootKey
}
// LockRootKey acquires an exclusive lock on the root key.
// This must be paired with a corresponding call to UnlockRootKey.
func LockRootKey() {
rootKeyMu.Lock()
}
// UnlockRootKey releases an exclusive lock on the root key previously
// acquired with LockRootKey.
func UnlockRootKey() {
rootKeyMu.Unlock()
}
// RootKeyZero checks if the root key contains only zero bytes.
//
// If the rot key is zero and SPIKE Nexus is not in "in memory" mode,
// then it means SPIKE Nexus has not been initialized yet, and any secret
// and policy management operation should be denied at the API level.
//
// Returns:
// - bool: true if the root key contains only zeroes, false otherwise
func RootKeyZero() bool {
rootKeyMu.RLock()
defer rootKeyMu.RUnlock()
for _, b := range rootKey[:] {
if b != 0 {
return false
}
}
return true
}
// SetRootKey updates the root key with the provided value.
//
// This function does not own its parameter; the `rk` argument can be (and
// should be) cleaned up after calling this function without impacting the
// saved root key.
//
// Security behavior:
// The application will crash (via log.FatalErr) if rk is nil or contains only
// zero bytes. This is a defense-in-depth measure: the caller (Initialize)
// already validates the key, but if somehow an invalid key reaches this
// function, crashing is the correct response. Operating with a nil or zero
// root key would mean secrets are unencrypted or encrypted with a predictable
// key, which is a critical security failure.
//
// Note: For in-memory backends, this function should not be called at all.
// The Initialize function handles this by returning early for memory backends.
//
// Parameters:
// - rk: Pointer to a 32-byte array containing the new root key value.
// Must be non-nil and non-zero.
func SetRootKey(rk *[crypto.AES256KeySize]byte) {
fName := "SetRootKey"
log.Info(fName, "message", "setting root key")
if rk == nil {
failErr := *sdkErrors.ErrRootKeyMissing.Clone()
log.FatalErr(fName, failErr)
return
}
if mem.Zeroed32(rk) {
failErr := *sdkErrors.ErrRootKeyEmpty.Clone()
log.FatalErr(fName, failErr)
}
rootKeyMu.Lock()
defer rootKeyMu.Unlock()
for i := range rootKey {
rootKey[i] = rk[i]
}
log.Info(fName, "message", "root key set")
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// Initialize initializes the backend storage with the provided root key.
//
// Security behavior:
// - For "in-memory" backends: The root key is not used; passing nil is safe.
// - For persistent backends (sqlite, lite): A valid, non-zero root key is
// required. The application will crash (via log.FatalErr) if the key is
// nil or zeroed. This is intentional: operating without a valid root key
// would leave all secrets unencrypted or encrypted with a predictable key,
// which is a critical security failure.
//
// The called SetRootKey function also validates the key as a defense-in-depth
// measure. If somehow an invalid key bypasses this function's validation,
// SetRootKey will also crash the application.
//
// Parameters:
// - r: Pointer to a 32-byte AES-256 root key. Must be non-nil and non-zero
// for persistent backends.
func Initialize(r *[crypto.AES256KeySize]byte) {
const fName = "Initialize"
log.Info(
fName,
"message", "initializing state",
"backendType", env.BackendStoreTypeVal(),
)
// Locks on a mutex; so only a single process can access it.
persist.InitializeBackend(r)
// The in-memory store does not use a root key to operate.
if env.BackendStoreTypeVal() == env.Memory {
log.Info(
fName,
"message", "state initialized (in-memory mode, root key not used)",
"backendType", env.BackendStoreTypeVal(),
)
return
}
if r == nil || mem.Zeroed32(r) {
failErr := *sdkErrors.ErrRootKeyEmpty.Clone()
log.FatalErr(fName, failErr)
}
// Update the internal root key.
// Locks on a mutex; so only a single process can modify the root key.
SetRootKey(r)
log.Info(
fName,
"message", "state initialized",
"backendType", env.BackendStoreTypeVal(),
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"context"
"sort"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// ListKeys returns a slice of strings containing all secret paths currently
// stored in the persistence backend. The function loads all secrets from the
// backend and extracts their paths for enumeration.
//
// The function uses a background context for the backend operation. If an error
// occurs while loading secrets from the backend, an empty slice is returned.
// The returned paths are sorted in lexicographical order for consistent
// ordering.
//
// Returns:
// - []string: A slice containing all secret paths in the backend, sorted
// lexicographically.
// Returns an empty slice if there are no secrets or if an error occurs.
//
// Example:
//
// keys := ListKeys()
// for _, key := range keys {
// fmt.Printf("Found key: %s\n", key)
// }
func ListKeys() []string {
ctx := context.Background()
secrets, err := persist.Backend().LoadAllSecrets(ctx)
if err != nil {
return []string{}
}
// Extract just the keys
keys := make([]string, 0, len(secrets))
for path := range secrets {
keys = append(keys, path)
}
// Sort for consistent ordering (lexicographical)
sort.Strings(keys)
return keys
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"context"
"regexp"
"time"
"github.com/google/uuid"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// 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.
//
// Parameters:
// - spiffeId: The SPIFFE ID of the requestor
// - path: The resource path being accessed
// - wants: Slice of permissions being requested
//
// Returns:
// - bool: true if access is granted, false otherwise
//
// 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
//
// A policy matches when:
//
// Its SPIFFE ID pattern matches the requestor's ID, and its path pattern
// matches the requested path.
func CheckAccess(
peerSPIFFEID string, path string, wants []data.PolicyPermission,
) bool {
const fName = "CheckAccess"
// Role:SpikePilot can always manage secrets and policies,
// and can call encryption and decryption API endpoints.
if spiffeid.IsPilotOperator(peerSPIFFEID) {
return true
}
policies, err := ListPolicies()
if err != nil {
log.WarnErr(fName, *sdkErrors.ErrEntityLoadFailed.Clone())
return false
}
for _, policy := range policies {
// Check specific patterns using pre-compiled regexes
if !policy.IDRegex.MatchString(peerSPIFFEID) {
continue
}
if !policy.PathRegex.MatchString(path) {
continue
}
if verifyPermissions(policy.Permissions, wants) {
return true
}
}
return false
}
// UpsertPolicy creates a new policy or updates an existing one with the same
// name. The function compiles regex patterns, generates a UUID for new policies,
// and sets timestamps appropriately before storing the policy.
//
// This function follows upsert semantics consistent with UpsertSecret:
// - If no policy with the given name exists, a new policy is created
// - If a policy with the same name exists, it is updated (ID and CreatedAt
// are preserved from the existing policy)
//
// Parameters:
// - policy: The policy to create or update. Must have a non-empty Name field.
// SPIFFEIDPattern and PathPattern MUST be valid regular expressions.
//
// Returns:
// - data.Policy: The created or updated policy, including ID and timestamps
// - *sdkErrors.SDKError: ErrEntityInvalid if the policy name is empty or
// regex patterns are invalid, ErrEntityLoadFailed or ErrEntitySaveFailed
// for backend errors
//
// The function performs the following:
// - Compiles and stores regex patterns for SPIFFEIDPattern and PathPattern
// - For new policies: generates a UUID, sets CreatedAt and UpdatedAt
// - For existing policies: preserves ID and CreatedAt, updates UpdatedAt
func UpsertPolicy(policy data.Policy) (data.Policy, *sdkErrors.SDKError) {
if policy.Name == "" {
return data.Policy{}, sdkErrors.ErrEntityInvalid.Clone()
}
ctx := context.Background()
// Check for existing policy with the same name
allPolicies, loadErr := persist.Backend().LoadAllPolicies(ctx)
if loadErr != nil {
return data.Policy{}, sdkErrors.ErrEntityLoadFailed.Wrap(loadErr)
}
var existingPolicy *data.Policy
for _, p := range allPolicies {
if p.Name == policy.Name {
pCopy := p
existingPolicy = pCopy
break
}
}
// Compile and validate patterns
idRegex, idCompileErr := regexp.Compile(policy.SPIFFEIDPattern)
if idCompileErr != nil {
idPatternErr := sdkErrors.ErrEntityInvalid.Clone()
idPatternErr.Msg = "invalid SPIFFE ID pattern: " + policy.SPIFFEIDPattern +
" for policy " + policy.Name
return data.Policy{}, idPatternErr.Wrap(idCompileErr)
}
policy.IDRegex = idRegex
pathRegex, pathCompileErr := regexp.Compile(policy.PathPattern)
if pathCompileErr != nil {
pathPatternErr := sdkErrors.ErrEntityInvalid.Clone()
pathPatternErr.Msg = "invalid path pattern: " + policy.PathPattern +
" for policy " + policy.Name
return data.Policy{}, pathPatternErr.Wrap(pathCompileErr)
}
policy.PathRegex = pathRegex
now := time.Now()
if existingPolicy != nil {
// Update existing policy: preserve ID and CreatedAt, set UpdatedAt
policy.ID = existingPolicy.ID
policy.CreatedAt = existingPolicy.CreatedAt
policy.UpdatedAt = now
} else {
// New policy: generate ID and set creation time
policy.ID = uuid.New().String()
if policy.CreatedAt.IsZero() {
policy.CreatedAt = now
}
policy.UpdatedAt = now
}
// Store to the backend
storeErr := persist.Backend().StorePolicy(ctx, policy)
if storeErr != nil {
saveErr := sdkErrors.ErrEntitySaveFailed.Clone()
saveErr.Msg = "failed to store policy " + policy.Name
return data.Policy{}, saveErr.Wrap(storeErr)
}
return policy, nil
}
// GetPolicy retrieves a policy by its ID from the policy store.
//
// Parameters:
// - id: The unique identifier of the policy to retrieve
//
// Returns:
// - data.Policy: The retrieved policy if found
// - *sdkErrors.SDKError: ErrEntityNotFound if no policy exists with the
// given ID, ErrEntityLoadFailed if loading fails
func GetPolicy(id string) (data.Policy, *sdkErrors.SDKError) {
ctx := context.Background()
// Load directly from the backend
policy, loadErr := persist.Backend().LoadPolicy(ctx, id)
if loadErr != nil {
getPolicyErr := sdkErrors.ErrEntityLoadFailed.Clone()
getPolicyErr.Msg = "failed to load policy with ID " + id
return data.Policy{}, getPolicyErr.Wrap(loadErr)
}
if policy == nil {
notFoundErr := sdkErrors.ErrEntityNotFound.Clone()
notFoundErr.Msg = "policy with ID " + id + " not found"
return data.Policy{}, notFoundErr
}
return *policy, nil
}
// DeletePolicy removes a policy from the system by its ID.
//
// Parameters:
// - id: The unique identifier of the policy to delete
//
// Returns:
// - *sdkErrors.SDKError: ErrEntityNotFound if no policy exists with the
// given ID, ErrObjectDeletionFailed if deletion fails, nil on success
func DeletePolicy(id string) *sdkErrors.SDKError {
ctx := context.Background()
// Check if the policy exists first (to maintain the same error behavior)
policy, loadErr := persist.Backend().LoadPolicy(ctx, id)
if loadErr != nil {
loadPolicyErr := sdkErrors.ErrEntityLoadFailed.Clone()
loadPolicyErr.Msg = "failed to load policy with ID " + id
return loadPolicyErr.Wrap(loadErr)
}
if policy == nil {
notFoundErr := sdkErrors.ErrEntityNotFound.Clone()
notFoundErr.Msg = "policy with ID " + id + " not found"
return notFoundErr
}
// Delete the policy from the backend
deleteErr := persist.Backend().DeletePolicy(ctx, id)
if deleteErr != nil {
deletePolicyErr := sdkErrors.ErrEntityDeletionFailed.Clone()
deletePolicyErr.Msg = "failed to delete policy with ID " + id
return deletePolicyErr.Wrap(deleteErr)
}
return nil
}
// ListPolicies retrieves all policies from the policy store.
// It iterates through the concurrent map of policies and returns them as a
// slice.
//
// Returns:
// - []data.Policy: A slice containing all existing policies. Returns an empty
// slice if no policies exist. The order of policies in the returned slice
// is non-deterministic due to the concurrent nature of the underlying
// store.
// - *sdkErrors.SDKError: ErrEntityLoadFailed if loading fails, nil on success
func ListPolicies() ([]data.Policy, *sdkErrors.SDKError) {
ctx := context.Background()
// Load all policies from the backend
allPolicies, loadErr := persist.Backend().LoadAllPolicies(ctx)
if loadErr != nil {
listPoliciesErr := sdkErrors.ErrEntityLoadFailed.Clone()
listPoliciesErr.Msg = "failed to load all policies"
return nil, listPoliciesErr.Wrap(loadErr)
}
// Convert map to slice
result := make([]data.Policy, 0, len(allPolicies))
for _, policy := range allPolicies {
if policy != nil {
result = append(result, *policy)
}
}
return result, nil
}
// ListPoliciesByPathPattern returns all policies that match a specific
// pathPattern pattern. It filters the policy store and returns only policies
// where PathPattern exactly matches the provided pattern string.
//
// Parameters:
// - pathPattern: The exact pathPattern pattern to match against policies
//
// Returns:
// - []data.Policy: A slice of policies with matching PathPattern. Returns an
// empty slice if no policies match. The order of policies in the returned
// slice is non-deterministic due to the concurrent nature of the underlying
// store.
// - *sdkErrors.SDKError: ErrEntityLoadFailed if loading fails, nil on success
func ListPoliciesByPathPattern(
pathPattern string,
) ([]data.Policy, *sdkErrors.SDKError) {
ctx := context.Background()
// Load all policies from the backend
allPolicies, loadErr := persist.Backend().LoadAllPolicies(ctx)
if loadErr != nil {
listByPathErr := sdkErrors.ErrEntityLoadFailed.Clone()
listByPathErr.Msg = "failed to load policies by pathPattern " + pathPattern
return nil, listByPathErr.Wrap(loadErr)
}
// Filter by pathPattern pattern
var result []data.Policy
for _, policy := range allPolicies {
if policy != nil && policy.PathPattern == pathPattern {
result = append(result, *policy)
}
}
return result, nil
}
// ListPoliciesBySPIFFEIDPattern returns all policies that match a specific
// SPIFFE ID pattern. It filters the policy store and returns only policies
// where SpiffeIdPattern exactly matches the provided pattern string.
//
// Parameters:
// - spiffeIdPattern: The exact SPIFFE ID pattern to match against policies
//
// Returns:
// - []data.Policy: A slice of policies with matching SpiffeIdPattern. Returns
// an empty slice if no policies match. The order of policies in the
// returned slice is non-deterministic due to the concurrent nature of the
// underlying store.
// - *sdkErrors.SDKError: ErrEntityLoadFailed if loading fails, nil on success
func ListPoliciesBySPIFFEIDPattern(
SPIFFEIDPattern string,
) ([]data.Policy, *sdkErrors.SDKError) {
ctx := context.Background()
// Load all policies from the backend.
allPolicies, loadErr := persist.Backend().LoadAllPolicies(ctx)
if loadErr != nil {
listByIDErr := sdkErrors.ErrEntityLoadFailed.Clone()
listByIDErr.Msg = "failed to load policies" +
" by SPIFFE ID pattern " + SPIFFEIDPattern
return nil, listByIDErr.Wrap(loadErr)
}
// Filter by SPIFFE ID pattern
var result []data.Policy
for _, policy := range allPolicies {
if policy != nil && policy.SPIFFEIDPattern == SPIFFEIDPattern {
result = append(result, *policy)
}
}
return result, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"context"
"fmt"
"sort"
"time"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/kv"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// UpsertSecret stores or updates a secret at the specified path with the
// provided values. It handles version management, maintaining a history of
// secret values up to the configured maximum number of versions.
//
// For new secrets, it creates the initial version (version 1). For existing
// secrets, it increments the version number and adds the new values while
// preserving history. Old versions are automatically pruned when the total
// number of versions exceeds the configured maximum.
//
// All operations are performed directly against the backing store without
// caching, ensuring consistency across multiple instances in high-availability
// deployments.
//
// Parameters:
// - path: The namespace path where the secret should be stored
// - values: A map containing the secret key-value pairs to be stored
//
// Returns:
// - *sdkErrors.SDKError: An error if the operation fails. Returns nil on
// success
//
// Example:
//
// err := UpsertSecret("app/database/credential", map[string]string{
// "username": "admin",
// "password": "SPIKE_Rocks",
// })
// if err != nil {
// log.Printf("Failed to store secret: %v", err)
// }
func UpsertSecret(path string, values map[string]string) *sdkErrors.SDKError {
ctx := context.Background()
// Load the current secret (if it exists) to handle versioning.
// ErrEntityNotFound means the secret doesn't exist yet, which is fine for
// upsert semantics. Any other error indicates a backend problem.
currentSecret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
if !err.Is(sdkErrors.ErrEntityNotFound) {
failErr := sdkErrors.ErrEntityLoadFailed.Wrap(err)
failErr.Msg = "failed to load secret with path " + path
return failErr
}
// Secret doesn't exist: currentSecret remains nil, and we'll create it
currentSecret = nil
}
now := time.Now()
// Build the secret structure
var secret *kv.Value
if currentSecret == nil {
// New secret - create from scratch
secret = &kv.Value{
Versions: map[int]kv.Version{
1: {
Data: values,
CreatedTime: now,
Version: 1,
DeletedTime: nil,
},
},
Metadata: kv.Metadata{
CreatedTime: now,
UpdatedTime: now,
CurrentVersion: 1,
OldestVersion: 1,
MaxVersions: env.MaxSecretVersionsVal(), // Get from the environment
},
}
} else {
// Existing secret - increment version
var newVersion int
if currentSecret.Metadata.CurrentVersion == 0 {
// All versions are deleted - find the highest existing version
// and increment to avoid collision with deleted versions
maxVersion := 0
for v := range currentSecret.Versions {
if v > maxVersion {
maxVersion = v
}
}
newVersion = maxVersion + 1
} else {
newVersion = currentSecret.Metadata.CurrentVersion + 1
}
// Add the new version
currentSecret.Versions[newVersion] = kv.Version{
Data: values,
CreatedTime: now,
Version: newVersion,
DeletedTime: nil,
}
// Update metadata
currentSecret.Metadata.CurrentVersion = newVersion
currentSecret.Metadata.UpdatedTime = now
// Clean up old versions if exceeding MaxVersions
if len(currentSecret.Versions) > currentSecret.Metadata.MaxVersions {
// Find and remove oldest versions
cmm := currentSecret.Metadata.MaxVersions
versionsToDelete := len(currentSecret.Versions) - cmm
sortedVersions := make([]int, 0, len(currentSecret.Versions))
for v := range currentSecret.Versions {
sortedVersions = append(sortedVersions, v)
}
sort.Ints(sortedVersions)
for i := 0; i < versionsToDelete; i++ {
delete(currentSecret.Versions, sortedVersions[i])
}
// Update OldestVersion
if len(sortedVersions) > versionsToDelete {
currentSecret.Metadata.OldestVersion = sortedVersions[versionsToDelete]
}
}
secret = currentSecret
}
// Store to the backend
err = persist.Backend().StoreSecret(ctx, path, *secret)
if err != nil {
return err
}
return nil
}
// DeleteSecret soft-deletes one or more versions of a secret at the specified
// path by marking them with DeletedTime. Deleted versions remain in storage
// and can be restored using UndeleteSecret.
//
// If the current version is deleted, CurrentVersion is updated to the highest
// remaining non-deleted version, or set to 0 if all versions are deleted.
// OldestVersion is also updated to reflect the oldest non-deleted version.
//
// Parameters:
// - path: The namespace path of the secret to delete
// - versions: A slice of version numbers to delete. If empty, deletes the
// current version only. Version number 0 represents the current version.
//
// Returns:
// - *sdkErrors.SDKError: An error if the operation fails. Returns nil on
// success.
func DeleteSecret(path string, versions []int) *sdkErrors.SDKError {
secret, err := loadAndValidateSecret(path)
if err != nil {
return err
}
ctx := context.Background()
// If no versions specified OR version 0 specified, delete the current version
if len(versions) == 0 {
versions = []int{secret.Metadata.CurrentVersion}
} else {
// Replace any 0s with the current version
for i, v := range versions {
if v == 0 {
versions[i] = secret.Metadata.CurrentVersion
}
}
}
// Mark specified versions as deleted
now := time.Now()
deletingCurrent := false
for _, version := range versions {
if v, exists := secret.Versions[version]; exists {
v.DeletedTime = &now
secret.Versions[version] = v
if version == secret.Metadata.CurrentVersion {
deletingCurrent = true
}
}
}
// If we deleted the current version, find the highest non-deleted version
if deletingCurrent {
newCurrent := 0 // Start at 0 (meaning "no valid version")
for version, v := range secret.Versions {
if v.DeletedTime == nil && version > newCurrent {
newCurrent = version
}
}
secret.Metadata.CurrentVersion = newCurrent
secret.Metadata.UpdatedTime = now
}
// Update OldestVersion to track the oldest non-deleted version
oldestVersion := 0
for version, v := range secret.Versions {
if v.DeletedTime == nil {
if oldestVersion == 0 || version < oldestVersion {
oldestVersion = version
}
}
}
secret.Metadata.OldestVersion = oldestVersion
// Store the updated secret back to the backend
err = persist.Backend().StoreSecret(ctx, path, *secret)
if err != nil {
return err
}
return nil
}
// UndeleteSecret restores previously deleted versions of a secret at the
// specified path by clearing their DeletedTime. If no versions are specified,
// it undeletes the current version (or the highest deleted version if all are
// deleted).
//
// If a version higher than CurrentVersion is undeleted, CurrentVersion is
// updated to that version. OldestVersion is also updated to reflect the oldest
// non-deleted version after the undelete operation.
//
// Versions that don't exist or are already undeleted are silently skipped.
//
// Parameters:
// - path: The namespace path of the secret to restore
// - versions: A slice of version numbers to restore. If empty, restores the
// current version (or latest deleted if CurrentVersion is 0). Version
// number 0 represents the current version.
//
// Returns:
// - *sdkErrors.SDKError: An error if no versions were undeleted or if the
// operation fails. Returns nil on success.
//
// Example:
//
// // Restore versions 1 and 3 of a secret
// err := UndeleteSecret("app/secrets/api-key", []int{1, 3})
func UndeleteSecret(path string, versions []int) *sdkErrors.SDKError {
secret, err := loadAndValidateSecret(path)
if err != nil {
return err
}
ctx := context.Background()
currentVersion := secret.Metadata.CurrentVersion
// If no versions specified,
// undelete the current version (or latest if current is 0)
if len(versions) == 0 {
// If CurrentVersion is 0 (all deleted), find the highest deleted version
if currentVersion == 0 {
highestDeleted := 0
for version, v := range secret.Versions {
if v.DeletedTime != nil && version > highestDeleted {
highestDeleted = version
}
}
if highestDeleted > 0 {
versions = []int{highestDeleted}
} else {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"could not find any secret to undelete at path %s for versions %v",
path, versions,
)
return &failErr
}
} else {
versions = []int{currentVersion}
}
}
// Undelete specific versions
anyUndeleted := false
highestUndeleted := 0
for _, version := range versions {
// Handle version 0 (current version)
if version == 0 {
if currentVersion == 0 {
continue // Can't undelete "current" when there is no current
}
version = currentVersion
}
if v, exists := secret.Versions[version]; exists {
if v.DeletedTime != nil {
v.DeletedTime = nil // Mark as undeleted
secret.Versions[version] = v
anyUndeleted = true
if version > highestUndeleted {
highestUndeleted = version
}
}
}
}
if !anyUndeleted {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"could not find any secret to undelete at path %s for versions %v",
path, versions,
)
return &failErr
}
// Update CurrentVersion if we undeleted a higher version than current
if highestUndeleted > secret.Metadata.CurrentVersion {
secret.Metadata.CurrentVersion = highestUndeleted
secret.Metadata.UpdatedTime = time.Now()
}
// Update OldestVersion to track the oldest non-deleted version
oldestVersion := 0
for version, v := range secret.Versions {
if v.DeletedTime == nil {
if oldestVersion == 0 || version < oldestVersion {
oldestVersion = version
}
}
}
secret.Metadata.OldestVersion = oldestVersion
// Store the updated secret back to the backend
err = persist.Backend().StoreSecret(ctx, path, *secret)
if err != nil {
return err
}
return nil
}
// GetSecret retrieves the data for a specific version of a secret at the
// specified path. Deleted versions return an error.
//
// Parameters:
// - path: The namespace path of the secret to retrieve
// - version: The specific version to fetch. Version 0 represents the current
// version. Returns an error if CurrentVersion is 0 (all deleted).
//
// Returns:
// - map[string]string: The secret key-value pairs for the requested version
// - *sdkErrors.SDKError: An error if the secret/version is not found, is
// deleted, or is empty. Returns nil on success.
func GetSecret(
path string, version int,
) (map[string]string, *sdkErrors.SDKError) {
secret, err := loadAndValidateSecret(path)
if err != nil {
return nil, err
}
// Handle version 0 (current version)
if version == 0 {
version = secret.Metadata.CurrentVersion
if version == 0 {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf("secret with path %s is empty", path)
return nil, &failErr
}
}
// Get the specific version
v, exists := secret.Versions[version]
if !exists {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"secret with path %s not found for version %v",
path, version,
)
return nil, &failErr
}
// Check if the version is deleted
if v.DeletedTime != nil {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"secret with path %s is marked deleted for version %v",
path, version,
)
return nil, &failErr
}
return v.Data, nil
}
// GetRawSecret retrieves the complete secret structure, including all versions
// and metadata from the specified path. The requested version must exist and
// be non-deleted, but the entire secret structure is returned.
//
// Parameters:
// - path: The namespace path of the secret to retrieve
// - version: The version to validate. Version 0 represents the current
// version. Returns an error if CurrentVersion is 0 (all deleted).
//
// Returns:
// - *kv.Value: The complete secret structure with all versions and metadata
// - *sdkErrors.SDKError: An error if the secret is not found, the requested
// version doesn't exist or is deleted, or the secret is empty. Returns
// nil on success.
func GetRawSecret(path string, version int) (*kv.Value, *sdkErrors.SDKError) {
secret, err := loadAndValidateSecret(path)
if err != nil {
return nil, err
}
// Validate the requested version exists and is not deleted
checkVersion := version
if wantsCurrentVersion := checkVersion == 0; wantsCurrentVersion {
// Explicitly switch to the current version if the version is 0
checkVersion = secret.Metadata.CurrentVersion
if emptySecret := checkVersion == 0; emptySecret {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf("secret with path %s is empty", path)
return nil, &failErr
}
}
v, exists := secret.Versions[checkVersion]
if !exists {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"secret with path %s not found for version %v",
path, checkVersion,
)
return nil, &failErr
}
if v.DeletedTime != nil {
failErr := *sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = fmt.Sprintf(
"secret with path %s is marked deleted for version %v",
path, checkVersion,
)
return nil, &failErr
}
// Return the full secret, since we've validated the requested
// version exists and is not deleted
return secret, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"crypto/rand"
"os"
"testing"
"github.com/spiffe/spike-sdk-go/crypto"
)
// Helper function to manage environment variables in tests
func withEnvironment(_ *testing.T, key, value string, testFunc func()) {
original := os.Getenv(key)
_ = os.Setenv(key, value)
defer func() {
if original != "" {
_ = os.Setenv(key, original)
} else {
_ = os.Unsetenv(key)
}
}()
testFunc()
}
// Helper function to create a test key with a specific pattern
func createTestKeyWithPattern(pattern byte) *[crypto.AES256KeySize]byte {
key := &[crypto.AES256KeySize]byte{}
for i := range key {
key[i] = pattern
}
return key
}
// Helper function to reset the root key to its zero state for tests
func resetRootKey() {
rootKeyMu.Lock()
defer rootKeyMu.Unlock()
for i := range rootKey {
rootKey[i] = 0
}
}
// Helper function to set the root key directly for testing (bypasses validation)
func setRootKeyDirect(key *[crypto.AES256KeySize]byte) {
rootKeyMu.Lock()
defer rootKeyMu.Unlock()
if key != nil {
copy(rootKey[:], key[:])
}
}
// Helper function to create a test key with random data
func createTestKey(t *testing.T) *[crypto.AES256KeySize]byte {
key := &[crypto.AES256KeySize]byte{}
if _, err := rand.Read(key[:]); err != nil {
t.Fatalf("Failed to generate test key: %v", err)
}
return key
}
// Helper function to create a test key with a specific pattern
func createPatternKey(pattern byte) *[crypto.AES256KeySize]byte {
key := &[crypto.AES256KeySize]byte{}
for i := range key {
key[i] = pattern
}
return key
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
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"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// loadAndValidateSecret loads a secret from the backend and validates that it
// exists. This helper function encapsulates the common pattern of loading and
// validating secrets used across multiple functions.
//
// Parameters:
// - path: The namespace path of the secret to load.
//
// Returns:
// - *kv.Value: The loaded and decrypted secret value.
// - *sdkErrors.SDKError: An error if loading fails or the secret does not
// exist. Returns nil on success.
func loadAndValidateSecret(path string) (*kv.Value, *sdkErrors.SDKError) {
ctx := context.Background()
// Load the secret from the backing store
secret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return nil, err
}
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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"github.com/spiffe/spike/app/nexus/internal/state/backend"
)
// Backend returns the currently initialized backend storage instance.
//
// Returns:
// - A backend.Backend interface pointing to the current backend instance:
// - memoryBackend for 'memory' store type or unknown types
// - sqliteBackend for 'sqlite' store type
//
// The return value is determined by env.BackendStoreType():
// - env.Memory: Returns the memory backend instance
// - env.Sqlite: Returns the SQLite backend instance
// - default: Falls back to the memory backend instance
//
// This function is safe for concurrent access. It uses an atomic pointer to
// retrieve the backend reference, ensuring that callers always get a consistent
// view of the backend even if InitializeBackend is called concurrently.
//
// Note: Once a backend reference is returned, it remains valid for the
// lifetime of that backend instance. If InitializeBackend is called again,
// new calls to Backend() will return the new instance, but existing references
// remain valid until their operations complete.
func Backend() backend.Backend {
ptr := backendPtr.Load()
if ptr == nil {
return nil
}
return *ptr
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
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"
)
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.
//
// Parameters:
// - rootKey: The encryption key used for backend initialization (used by
// SQLite backend)
//
// Returns:
// - A backend.Backend interface implementation:
// - memory.Store for 'memory' store type or unknown types
// - SQLite backend for 'sqlite' store type
//
// The actual backend type is determined by env.BackendStoreType():
// - env.Memory: Returns a no-op memory store
// - env.Sqlite: Initializes and returns a SQLite backend
// - default: Falls back to a no-op memory store
//
// The function is safe for concurrent access as it uses a mutex to protect the
// initialization process.
//
// 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)
}
} 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)
}
}
backendMu.Lock()
defer backendMu.Unlock()
storeType := env.BackendStoreTypeVal()
switch storeType {
case env.Lite:
be = initializeLiteBackend(rootKey)
case env.Memory:
be = initializeInMemoryBackend()
case env.Sqlite:
be = initializeSqliteBackend(rootKey)
default:
be = initializeInMemoryBackend()
}
// Store the backend atomically for safe concurrent access.
backendPtr.Store(&be)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
"github.com/spiffe/spike/app/nexus/internal/state/backend/lite"
)
// initializeLiteBackend creates and initializes a Lite backend instance
// using the provided root key for encryption. The Lite backend is a
// lightweight alternative to SQLite for persistent storage. The Lite mode
// does not use any backing store and relies on persisting encrypted data
// on object storage (like S3, or Minio).
//
// Parameters:
// - rootKey: A 32-byte encryption key used to secure the Lite database.
// The backend will use this key directly for encryption operations.
//
// Returns:
// - backend.Backend: An initialized Lite backend instance
//
// Error Handling:
// If the backend creation fails, the function calls log.FatalErr() which
// terminates the program. This is a fatal error because the system cannot
// operate without a properly initialized backend when Lite mode is
// configured.
//
// Example:
//
// var rootKey [32]byte
// // ... populate rootKey with secure random data ...
// backend := initializeLiteBackend(&rootKey)
// // backend is guaranteed to be valid; function exits on error
//
// Note: Unlike the SQLite backend, the Lite backend does not require a
// separate Initialize() call or timeout configuration.
func initializeLiteBackend(
rootKey *[crypto.AES256KeySize]byte,
) backend.Backend {
const fName = "initializeLiteBackend"
dbBackend, err := lite.New(rootKey)
if err != nil {
err.Msg = "failed to initialize lite backend"
log.FatalErr(fName, *err)
}
return dbBackend
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
"github.com/spiffe/spike/app/nexus/internal/state/backend/memory"
)
// initializeInMemoryBackend creates and returns a new in-memory backend
// instance. It configures the backend with the system cipher and maximum
// secret versions from the environment configuration.
//
// Returns a Backend implementation that stores all data in memory without
// 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())
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"context"
"encoding/hex"
"github.com/spiffe/spike-sdk-go/config/env"
"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/app/nexus/internal/state/backend"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite"
)
// initializeSqliteBackend creates and initializes an SQLite backend instance
// using the provided root key for encryption. The backend is configured using
// environment variables for database settings such as directory location,
// connection limits, and journal mode.
//
// Parameters:
// - rootKey: The encryption key used to secure the SQLite database
//
// Returns:
// - A backend.Backend interface if successful, nil if initialization fails
//
// The function attempts two main operations:
// 1. Creating the SQLite backend with the provided configuration
// 2. Initializing the backend with a 30-second timeout
//
// If either operation fails, it logs a warning and returns nil. This allows
// the system to continue operating with an in-memory state only. Configuration
// options include:
// - Database directory and filename
// - Journal mode settings
// - Connection pool settings (max open, max idle, lifetime)
// - Busy timeout settings
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)
}
opts := map[backend.DatabaseConfigKey]any{}
opts[backend.KeyDataDir] = fs.NexusDataFolder()
opts[backend.KeyDatabaseFile] = dbName
opts[backend.KeyJournalMode] = env.DatabaseJournalModeVal()
opts[backend.KeyBusyTimeoutMs] = env.DatabaseBusyTimeoutMsVal()
opts[backend.KeyMaxOpenConns] = env.DatabaseMaxOpenConnsVal()
opts[backend.KeyMaxIdleConns] = env.DatabaseMaxIdleConnsVal()
opts[backend.KeyConnMaxLifetimeSeconds] = env.DatabaseConnMaxLifetimeSecVal()
// Create SQLite backend configuration
cfg := backend.Config{
// Use a copy of the root key as the encryption key.
// The caller will securely zero out the original root key.
EncryptionKey: hex.EncodeToString(rootKey[:]),
Options: opts,
}
// Initialize SQLite backend
dbBackend, err := sqlite.New(cfg)
if err != nil {
failErr := sdkErrors.ErrStateInitializationFailed.Wrap(err)
failErr.Msg = "failed to create SQLite backend"
// Log error but don't fail initialization
// The system can still work with just in-memory state
log.WarnErr(fName, *failErr)
return nil
}
ctxC, cancel := context.WithTimeout(
context.Background(), env.DatabaseInitializationTimeoutVal(),
)
defer cancel()
if initErr := dbBackend.Initialize(ctxC); initErr != nil {
failErr := sdkErrors.ErrStateInitializationFailed.Wrap(initErr)
failErr.Msg = "failed to initialize SQLite backend"
log.WarnErr(fName, *failErr)
return nil
}
return dbBackend
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package persist
import (
"os"
"path/filepath"
"testing"
"github.com/spiffe/spike-sdk-go/config/fs"
"github.com/spiffe/spike-sdk-go/crypto"
)
// cleanupSQLiteDatabase removes the existing SQLite database to ensure a clean test state
func cleanupSQLiteDatabase(t *testing.T) {
dataDir := fs.NexusDataFolder()
dbPath := filepath.Join(dataDir, "spike.db")
// Remove the database file if it exists
if _, err := os.Stat(dbPath); err == nil {
t.Logf("Removing existing database at %s", dbPath)
if err := os.Remove(dbPath); err != nil {
t.Logf("Warning: Failed to remove existing database: %v", err)
}
}
}
// Helper function to create a test root key with a specific pattern
func createTestKey(_ *testing.T) *[crypto.AES256KeySize]byte {
key := &[crypto.AES256KeySize]byte{}
for i := range key {
key[i] = byte(i % 256) // Predictable pattern for testing
}
return key
}
// Helper function to create a zero key
func createZeroKey() *[crypto.AES256KeySize]byte {
return &[crypto.AES256KeySize]byte{} // All zeros
}
// TestingInterface allows both *testing.T and *testing.B to be used
type TestingInterface interface {
Fatalf(format string, args ...interface{})
Errorf(format string, args ...interface{})
Error(args ...interface{})
Fatal(args ...interface{})
}
// Helper function to set the environment variable and restore it after test
func withEnvironment(_ TestingInterface, key, value string, testFunc func()) {
original := os.Getenv(key)
defer func() {
if original != "" {
_ = os.Setenv(key, original)
} else {
_ = os.Unsetenv(key)
}
}()
if value != "" {
_ = os.Setenv(key, value)
} else {
_ = os.Unsetenv(key)
}
testFunc()
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"context"
"fmt"
"os"
"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/security/mem"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike/app/spike/internal/cmd"
)
const appName = "SPIKE"
func main() {
errMem := mem.Lock()
if errMem != nil {
if env.ShowMemoryWarningVal() {
if _, err := fmt.Fprintln(os.Stderr, `
Memory locking is not available.
Consider disabling swap to enhance security.
`); err != nil {
fmt.Println("failed to write to stderr: ", err.Error())
}
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
source, SPIFFEID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
failErr := sdkErrors.ErrStateInitializationFailed.Wrap(err)
log.FatalErr(appName, *failErr)
}
defer func() {
if closeErr := spiffe.CloseSource(source); closeErr != nil {
warnErr := sdkErrors.ErrSPIFFEFailedToCloseX509Source.Wrap(closeErr)
log.WarnErr(appName, *warnErr)
}
}()
cmd.Initialize(source, SPIFFEID)
cmd.Execute()
}
// \\ 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/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
// NewCommand creates a new top-level command for cryptographic
// operations. It acts as a parent for all cipher-related subcommands:
// encrypt and decrypt.
//
// The cipher commands provide encryption and decryption capabilities through
// SPIKE Nexus, allowing workloads to protect sensitive data in transit or at
// rest. Operations can work with files, stdin/stdout, or base64-encoded
// strings.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable. Subcommands will check for nil
// and display user-friendly error messages instead of crashing.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured top-level Cobra command for cipher operations
//
// Available subcommands:
// - encrypt: Encrypt data via SPIKE Nexus
// - decrypt: Decrypt data via SPIKE Nexus
//
// Example usage:
//
// spike cipher encrypt --in secret.txt --out secret.enc
// spike cipher decrypt --in secret.enc --out secret.txt
// echo "sensitive data" | spike cipher encrypt | spike cipher decrypt
//
// Each subcommand supports multiple input/output modes including files,
// stdin/stdout streams, and base64-encoded strings. See the individual
// command documentation for details.
func NewCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "cipher",
Short: "Encrypt and decrypt data using SPIKE Nexus",
}
cmd.AddCommand(newEncryptCommand(source, SPIFFEID))
cmd.AddCommand(newDecryptCommand(source, SPIFFEID))
return cmd
}
// \\ 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/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"
)
// newDecryptCommand creates a Cobra command for decrypting data via SPIKE
// Nexus. The command supports two modes of operation:
//
// Stream Mode (default):
// - Reads encrypted data from a file (--file) or stdin
// - Writes decrypted plaintext to a file (--out) or stdout
// - Handles binary data transparently
//
// JSON Mode (when --version, --nonce, or --ciphertext is provided):
// - Accepts base64-encoded encryption components
// - Requires version byte (0-255), nonce, and ciphertext
// - Returns plaintext output
// - Allows algorithm specification via --algorithm flag
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable. If nil, the command will display
// a user-friendly error message and exit cleanly.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for decryption
//
// Flags:
// - --file, -f: Input file path (defaults to stdin)
// - --out, -o: Output file path (defaults to stdout)
// - --version: Version byte (0-255) for JSON mode
// - --nonce: Base64-encoded nonce for JSON mode
// - --ciphertext: Base64-encoded ciphertext for JSON mode
// - --algorithm: Algorithm hint for JSON mode
func newDecryptCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "decrypt",
Short: "Decrypt file or stdin via SPIKE Nexus",
Run: func(cmd *cobra.Command, args []string) {
trust.AuthenticateForPilot(SPIFFEID)
if source == nil {
cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.")
return
}
api := sdk.NewWithSource(source)
inFile, _ := cmd.Flags().GetString("file")
outFile, _ := cmd.Flags().GetString("out")
versionStr, _ := cmd.Flags().GetString("version")
nonceB64, _ := cmd.Flags().GetString("nonce")
ciphertextB64, _ := cmd.Flags().GetString("ciphertext")
algorithm, _ := cmd.Flags().GetString("algorithm")
jsonMode := versionStr != "" || nonceB64 != "" ||
ciphertextB64 != ""
if jsonMode {
decryptJSON(cmd, api, versionStr, nonceB64,
ciphertextB64, algorithm, outFile)
return
}
decryptStream(cmd, api, inFile, outFile)
},
}
cmd.Flags().StringP(
"file", "f", "", "Input file (default: stdin)",
)
cmd.Flags().StringP(
"out", "o", "", "Output file (default: stdout)",
)
cmd.Flags().String(
"version", "", "Version byte (0-255) for JSON mode",
)
cmd.Flags().String(
"nonce", "", "Nonce (base64) for JSON mode",
)
cmd.Flags().String(
"ciphertext", "", "Ciphertext (base64) for JSON mode",
)
cmd.Flags().String(
"algorithm", "", "Algorithm hint for JSON mode",
)
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\ SPDX-License-Identifier: Apache-2.0
package cipher
import (
"encoding/base64"
"os"
"strconv"
"github.com/spf13/cobra"
sdk "github.com/spiffe/spike-sdk-go/api"
"github.com/spiffe/spike/app/spike/internal/stdout"
)
// decryptStream performs stream-based decryption by reading from a file or
// stdin and writing the decrypted plaintext to a file or stdout.
//
// Parameters:
// - cmd: Cobra command for output
// - api: The SPIKE SDK API client
// - inFile: Input file path (empty string means stdin)
// - outFile: Output file path (empty string means stdout)
//
// The function prints errors directly to stderr and returns without error
// propagation, following the CLI command pattern.
func decryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) {
// Validate the input file exists before attempting decryption.
if inFile != "" {
if _, err := os.Stat(inFile); err != nil {
if os.IsNotExist(err) {
cmd.PrintErrf("Error: Input file does not exist: %s\n", inFile)
return
}
cmd.PrintErrf("Error: Cannot access input file: %s\n", inFile)
return
}
}
in, cleanupIn, inputErr := openInput(inFile)
if inputErr != nil {
cmd.PrintErrf("Error: %v\n", inputErr)
return
}
defer cleanupIn()
out, cleanupOut, outputErr := openOutput(outFile)
if outputErr != nil {
cmd.PrintErrf("Error: %v\n", outputErr)
return
}
defer cleanupOut()
plaintext, apiErr := api.CipherDecryptStream(in)
if stdout.HandleAPIError(cmd, apiErr) {
return
}
if _, writeErr := out.Write(plaintext); writeErr != nil {
cmd.PrintErrf("Error: Failed to write output: %v\n", writeErr)
return
}
}
// decryptJSON performs JSON-based decryption using base64-encoded components
// (version, nonce, ciphertext) and writes the decrypted plaintext to a file
// or stdout.
//
// Parameters:
// - cmd: Cobra command for output
// - api: The SPIKE SDK API client
// - versionStr: Version byte as a string (0-255)
// - nonceB64: Base64-encoded nonce
// - ciphertextB64: Base64-encoded ciphertext
// - algorithm: Algorithm hint for decryption
// - outFile: Output file path (empty string means stdout)
//
// The function prints errors directly to stderr and returns without error
// propagation, following the CLI command pattern.
func decryptJSON(cmd *cobra.Command, api *sdk.API, versionStr, nonceB64,
ciphertextB64, algorithm, outFile string) {
v, atoiErr := strconv.Atoi(versionStr)
// version must be a valid byte value.
if atoiErr != nil || v < 0 || v > 255 {
cmd.PrintErrln("Error: Invalid --version, must be 0-255.")
return
}
nonce, nonceErr := base64.StdEncoding.DecodeString(nonceB64)
if nonceErr != nil {
cmd.PrintErrln("Error: Invalid --nonce base64.")
return
}
ciphertext, ciphertextErr := base64.StdEncoding.DecodeString(ciphertextB64)
if ciphertextErr != nil {
cmd.PrintErrln("Error: Invalid --ciphertext base64.")
return
}
out, cleanupOut, openErr := openOutput(outFile)
if openErr != nil {
cmd.PrintErrf("Error: %v\n", openErr)
return
}
defer cleanupOut()
plaintext, apiErr := api.CipherDecrypt(
byte(v), nonce, ciphertext, algorithm,
)
if stdout.HandleAPIError(cmd, apiErr) {
return
}
if _, writeErr := out.Write(plaintext); writeErr != nil {
cmd.PrintErrf("Error: Failed to write plaintext: %v\n", writeErr)
return
}
}
// \\ 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/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"
)
// newEncryptCommand creates a Cobra command for encrypting data via SPIKE
// Nexus. The command supports two modes of operation:
//
// Stream Mode (default):
// - Reads data from a file (--file) or stdin
// - Writes encrypted data to a file (--out) or stdout
// - Handles binary data transparently
//
// JSON Mode (when --plaintext is provided):
// - Accepts base64-encoded plaintext
// - Returns JSON-formatted encryption result
// - Allows algorithm specification via --algorithm flag
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable. If nil, the command will display
// a user-friendly error message and exit cleanly.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for encryption
//
// Flags:
// - --file, -f: Input file path (defaults to stdin)
// - --out, -o: Output file path (defaults to stdout)
// - --plaintext: Base64-encoded plaintext for JSON mode
// - --algorithm: Algorithm hint for JSON mode
func newEncryptCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "encrypt",
Short: "Encrypt file or stdin via SPIKE Nexus",
Run: func(cmd *cobra.Command, args []string) {
trust.AuthenticateForPilot(SPIFFEID)
if source == nil {
cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.")
return
}
api := sdk.NewWithSource(source)
inFile, _ := cmd.Flags().GetString("file")
outFile, _ := cmd.Flags().GetString("out")
plaintextB64, _ := cmd.Flags().GetString("plaintext")
algorithm, _ := cmd.Flags().GetString("algorithm")
if plaintextB64 != "" {
encryptJSON(cmd, api, plaintextB64, algorithm, outFile)
return
}
encryptStream(cmd, api, inFile, outFile)
},
}
cmd.Flags().StringP(
"file", "f", "", "Input file (default: stdin)",
)
cmd.Flags().StringP(
"out", "o", "", "Output file (default: stdout)",
)
cmd.Flags().String(
"plaintext", "",
"Base64 plaintext for JSON mode; if set, uses JSON API",
)
cmd.Flags().String(
"algorithm", "", "Algorithm hint for JSON mode",
)
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package cipher
import (
"encoding/base64"
"os"
"github.com/spf13/cobra"
sdk "github.com/spiffe/spike-sdk-go/api"
"github.com/spiffe/spike/app/spike/internal/stdout"
)
// encryptStream performs stream-based encryption by reading from a file or
// stdin and writing the encrypted ciphertext to a file or stdout.
//
// Parameters:
// - cmd: Cobra command for output
// - api: The SPIKE SDK API client
// - inFile: Input file path (empty string means stdin)
// - outFile: Output file path (empty string means stdout)
//
// The function prints errors directly to stderr and returns without error
// propagation, following the CLI command pattern.
func encryptStream(cmd *cobra.Command, api *sdk.API, inFile, outFile string) {
// Validate the input file exists before attempting encryption.
if inFile != "" {
if _, err := os.Stat(inFile); err != nil {
if os.IsNotExist(err) {
cmd.PrintErrf("Error: Input file does not exist: %s\n", inFile)
return
}
cmd.PrintErrf("Error: Cannot access input file: %s\n", inFile)
return
}
}
in, cleanupIn, inputErr := openInput(inFile)
if inputErr != nil {
cmd.PrintErrf("Error: %v\n", inputErr)
return
}
defer cleanupIn()
out, cleanupOut, outputErr := openOutput(outFile)
if outputErr != nil {
cmd.PrintErrf("Error: %v\n", outputErr)
return
}
defer cleanupOut()
ciphertext, apiErr := api.CipherEncryptStream(in)
if stdout.HandleAPIError(cmd, apiErr) {
return
}
if _, writeErr := out.Write(ciphertext); writeErr != nil {
cmd.PrintErrf("Error: Failed to write ciphertext: %v\n", writeErr)
return
}
}
// encryptJSON performs JSON-based encryption using base64-encoded plaintext
// and writes the encrypted result to a file or stdout.
//
// Parameters:
// - cmd: Cobra command for output
// - api: The SPIKE SDK API client
// - plaintextB64: Base64-encoded plaintext
// - algorithm: Algorithm hint for encryption
// - outFile: Output file path (empty string means stdout)
//
// The function prints errors directly to stderr and returns without error
// propagation, following the CLI command pattern.
func encryptJSON(cmd *cobra.Command, api *sdk.API, plaintextB64, algorithm,
outFile string) {
plaintext, err := base64.StdEncoding.DecodeString(plaintextB64)
if err != nil {
cmd.PrintErrln("Error: Invalid --plaintext base64.")
return
}
out, cleanupOut, openErr := openOutput(outFile)
if openErr != nil {
cmd.PrintErrf("Error: %v\n", openErr)
return
}
defer cleanupOut()
ciphertext, apiErr := api.CipherEncrypt(plaintext, algorithm)
if stdout.HandleAPIError(cmd, apiErr) {
return
}
if _, writeErr := out.Write(ciphertext); writeErr != nil {
cmd.PrintErrf("Error: Failed to write ciphertext: %v\n", writeErr)
return
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package cipher
import (
"fmt"
"io"
"os"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// openInput opens a file for reading or returns stdin if no file is
// specified. The returned closer should be called to clean up resources.
//
// Parameters:
// - inFile: Input file path (empty string means stdin)
//
// Returns:
// - io.ReadCloser: Reader for the input source
// - func(): Cleanup function to close the file (safe to call always)
// - error: File opening errors
//
// The cleanup function is safe to call even if stdin is used (it will only
// close actual files, not stdin).
//
// Example usage:
//
// in, cleanup, err := openInput(inFile)
// if err != nil {
// return err
// }
// defer cleanup()
func openInput(inFile string) (io.ReadCloser, func(), *sdkErrors.SDKError) {
var in io.ReadCloser
if inFile != "" {
f, err := os.Open(inFile)
if err != nil {
failErr := sdkErrors.ErrFSFileOpenFailed.Wrap(err)
failErr.Msg = fmt.Sprintf("failed to open input file: %s", inFile)
return nil, func() {}, failErr
}
in = f
} else {
in = os.Stdin
}
cleanup := func() {
if in != os.Stdin {
// Best-effort close; nothing actionable if it fails.
_ = in.Close()
}
}
return in, cleanup, nil
}
// openOutput creates a file for writing or returns stdout if no file is
// specified. The returned closer should be called to clean up resources.
//
// Parameters:
// - outFile: Output file path (empty string means stdout)
//
// Returns:
// - io.Writer: Writer for the output destination
// - func(): Cleanup function to close the file (safe to call always)
// - *sdkErrors.SDKError: File creation errors
//
// The cleanup function is safe to call even if stdout is used (it will only
// close actual files, not stdout).
//
// Example usage:
//
// out, cleanup, err := openOutput(outFile)
// if err != nil {
// return err
// }
// defer cleanup()
func openOutput(outFile string) (io.Writer, func(), *sdkErrors.SDKError) {
var out io.Writer
var outCloser io.Closer
if outFile != "" {
f, err := os.Create(outFile)
if err != nil {
// Using ErrFSFileOpenFailed for file creation errors as well,
// since it represents file access failures in general
failErr := sdkErrors.ErrFSFileOpenFailed.Wrap(err)
failErr.Msg = fmt.Sprintf("failed to create output file: %s", outFile)
return nil, func() {}, failErr
}
out = f
outCloser = f
} else {
out = os.Stdout
}
cleanup := func() {
if outCloser != nil {
// Best-effort close; nothing actionable if it fails.
_ = outCloser.Close()
}
}
return out, cleanup, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package cmd
import (
"fmt"
"os"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/spike/app/spike/internal/cmd/cipher"
"github.com/spiffe/spike/app/spike/internal/cmd/operator"
"github.com/spiffe/spike/app/spike/internal/cmd/policy"
"github.com/spiffe/spike/app/spike/internal/cmd/secret"
)
// Initialize sets up the complete SPIKE CLI command structure by registering
// all top-level command groups with the root command. This function must be
// called before Execute to establish the command hierarchy.
//
// The following command groups are registered:
// - policy: Manage access control policies
// - secret: Manage secrets (CRUD operations)
// - cipher: Encrypt and decrypt data
// - operator: Operator functions (recover, restore)
//
// Each command group provides its own subcommands and flags. See the
// individual command documentation for details.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for workload authentication. Can be nil
// if the Workload API connection is unavailable. Individual subcommands
// will check for nil and display user-friendly error messages.
// - SPIFFEID: The SPIFFE ID used to authenticate with SPIKE Nexus
//
// Example usage:
//
// source, err := workloadapi.NewX509Source(ctx)
// if err != nil {
// log.Fatal(err)
// }
// Initialize(source, "spiffe://example.org/pilot")
// Execute()
func Initialize(source *workloadapi.X509Source, SPIFFEID string) {
rootCmd.AddCommand(policy.NewCommand(source, SPIFFEID))
rootCmd.AddCommand(secret.NewCommand(source, SPIFFEID))
rootCmd.AddCommand(cipher.NewCommand(source, SPIFFEID))
rootCmd.AddCommand(operator.NewCommand(source, SPIFFEID))
}
// Execute runs the root command and processes the entire command execution
// lifecycle. This function should be called after Initialize to start the CLI
// application.
//
// The function handles command execution and error reporting:
// - Executes the root command (and any subcommands)
// - Returns successfully (exit code 0) if no errors occur
// - Prints errors to stderr and exits with code 1 on failure
//
// Error handling:
// - Command errors are written to stderr
// - If stderr write fails, error is printed to stdout as fallback
// - Process exits with status code 1 on any error
//
// This function does not return on error; it terminates the process.
//
// Example usage:
//
// func main() {
// source, SPIFFEID, err := spiffe.Source(ctx)
// if err != nil {
// log.Fatal(err)
// }
// Initialize(source, SPIFFEID)
// Execute() // Does not return on error
// }
func Execute() {
var cmdErr error
if cmdErr = rootCmd.Execute(); cmdErr == nil {
return
}
// Try to write error to stderr
if _, err := fmt.Fprintf(os.Stderr, "%v\n", cmdErr); err != nil {
// Fallback to stdout if stderr is unavailable
_, _ = fmt.Fprintf(
os.Stdout, "Error: failed to write to stderr: %s\n", err.Error(),
)
}
os.Exit(1)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package format
import (
"fmt"
"github.com/spf13/cobra"
)
// OutputFormat represents the supported output formats.
type OutputFormat int
const (
// Human represents human-readable output format.
Human OutputFormat = iota
// JSON represents JSON output format.
JSON
// YAML represents YAML output format.
YAML
)
// String returns the canonical string representation of the format.
func (f OutputFormat) String() string {
switch f {
case Human:
return "human"
case JSON:
return "json"
case YAML:
return "yaml"
default:
return "unknown"
}
}
// AddFormatFlag adds a standardized format flag to the given command.
// The format flag supports the following options:
// - human, h, plain, p: Human-readable, friendly output (default)
// - json, j: Valid JSON output (for scripting/parsing)
// - yaml, y: Valid YAML output (for scripting/parsing)
//
// Parameters:
// - cmd: The Cobra command to add the flag to
func AddFormatFlag(cmd *cobra.Command) {
cmd.Flags().StringP("format", "f", "human",
"Output format: human/h/plain/p, json/j, or yaml/y")
}
// GetFormat retrieves and validates the format flag from the command.
// It supports multiple aliases for each format:
// - human, h, plain, p -> Human format
// - json, j -> JSON format
// - yaml, y -> YAML format
//
// Parameters:
// - cmd: The Cobra command containing the format flag
//
// Returns:
// - OutputFormat: The parsed output format
// - error: An error if the format is invalid
func GetFormat(cmd *cobra.Command) (OutputFormat, error) {
formatStr, _ := cmd.Flags().GetString("format")
return ParseFormat(formatStr)
}
// ParseFormat parses a format string into an OutputFormat.
// It supports multiple aliases for each format:
// - human, h, plain, p, "" (empty) -> Human format
// - json, j -> JSON format
// - yaml, y -> YAML format
//
// Parameters:
// - formatStr: The format string to parse
//
// Returns:
// - OutputFormat: The parsed output format
// - error: An error if the format is invalid
func ParseFormat(formatStr string) (OutputFormat, error) {
switch formatStr {
case "human", "h", "plain", "p", "":
return Human, nil
case "json", "j":
return JSON, nil
case "yaml", "y":
return YAML, nil
default:
return Human, fmt.Errorf(
"invalid format '%s'. Valid formats are: "+
"human/h/plain/p, json/j, yaml/y",
formatStr)
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
// NewCommand creates a new cobra.Command for managing SPIKE admin
// operations. It initializes an "operator" command with subcommands for
// recovery and restore operations.
//
// Parameters:
// - source: An X509Source used for SPIFFE authentication. Can be nil if the
// Workload API connection is unavailable. Subcommands will check for nil
// and display user-friendly error messages instead of crashing.
// - SPIFFEID: The SPIFFE ID associated with the operator
//
// Returns:
// - *cobra.Command: A configured cobra command for operator management
func NewCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "operator",
Short: "Manage admin operations",
}
cmd.AddCommand(newOperatorRecoverCommand(source, SPIFFEID))
cmd.AddCommand(newOperatorRestoreCommand(source, SPIFFEID))
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"runtime"
"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/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
// on SPIKE Nexus.
//
// This function creates a command that allows privileged operators with the
// 'recover' role to retrieve recovery shards from a healthy SPIKE Nexus system.
// The retrieved shards are saved to the configured recovery directory and can
// be used to restore the system in case of a catastrophic failure.
//
// Parameters:
// - source: X.509 source for SPIFFE authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID of the caller for role-based access control.
//
// Returns:
// - *cobra.Command: A cobra command that implements the recovery
// functionality.
//
// The command performs the following operations:
// - Verifies the caller has the 'recover' role, aborting otherwise.
// - Authenticates the recovery request.
// - Retrieves recovery shards from the SPIKE API.
// - Cleans the recovery directory of any previous recovery files.
// - Saves the retrieved shards as text files in the recovery directory.
// - Provides instructions to the operator about securing the recovery shards.
//
// The command will abort with a fatal error if:
// - The caller lacks the required 'recover' role.
// - The API call to retrieve shards fails.
// - Fewer than 2 shards are retrieved.
// - It fails to read or clean the recovery directory.
func newOperatorRecoverCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var recoverCmd = &cobra.Command{
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)
if source == nil {
cmd.PrintErrln("Error: SPIFFE X509 source is unavailable.")
return
}
api := spike.NewWithSource(source)
shards, apiErr := api.Recover()
// Security: clean the shards when we no longer need them.
defer func() {
for _, shard := range shards {
mem.ClearRawBytes(shard)
}
}()
if apiErr != nil {
cmd.PrintErrln("Error: Failed to retrieve recovery shards.")
return
}
if shards == nil {
cmd.PrintErrln("Error: No shards found.")
return
}
for _, shard := range shards {
emptyShard := true
for _, v := range shard {
if v != 0 {
emptyShard = false
break
}
}
if emptyShard {
cmd.PrintErrln("Error: Empty shard found.")
return
}
}
// Creates the folder if it does not exist.
recoverDir := fs.PilotRecoveryFolder()
// Clean the path to normalize it
cleanPath, absErr := filepath.Abs(filepath.Clean(recoverDir))
if absErr != nil {
cmd.PrintErrf("Error: %v\n", absErr)
return
}
// Verify the path exists and is a directory
fileInfo, statErr := os.Stat(cleanPath)
if statErr != nil || !fileInfo.IsDir() {
cmd.PrintErrln("Error: Invalid recovery directory path.")
return
}
// Ensure the cleaned path doesn't contain suspicious components
if strings.Contains(cleanPath, "..") ||
strings.Contains(cleanPath, "./") ||
strings.Contains(cleanPath, "//") {
cmd.PrintErrln("Error: Invalid recovery directory path.")
return
}
// Ensure the recover directory is clean by
// deleting any existing recovery files.
if _, dirStatErr := os.Stat(recoverDir); dirStatErr == nil {
files, readErr := os.ReadDir(recoverDir)
if readErr != nil {
cmd.PrintErrf("Error: Failed to read recover directory: %v\n",
readErr)
return
}
for _, file := range files {
if file.Name() != "" && filepath.Ext(file.Name()) == ".txt" &&
strings.HasPrefix(file.Name(), "spike.recovery") {
filePath := filepath.Join(recoverDir, file.Name())
_ = os.Remove(filePath)
}
}
}
// Save each shard to a file
for i, shard := range shards {
filePath := fmt.Sprintf("%s/spike.recovery.%d.txt", recoverDir, i)
encodedShard := hex.EncodeToString(shard[:])
out := fmt.Sprintf("spike:%d:%s", i, encodedShard)
// 0600 to be more restrictive.
writeErr := os.WriteFile(filePath, []byte(out), 0600)
// Security: Hint gc to reclaim memory.
encodedShard = "" // nolint:ineffassign
out = "" // nolint:ineffassign
runtime.GC()
if writeErr != nil {
cmd.PrintErrf("Error: Failed to save shard %d: %v\n",
i, writeErr)
return
}
}
cmd.Println("")
cmd.Println(
" SPIKE Recovery shards saved to the recovery directory:")
cmd.Println(" " + recoverDir)
cmd.Println("")
cmd.Println(" Please make sure that:")
cmd.Println(" 1. You encrypt these shards and keep them safe.")
cmd.Println(" 2. Securely erase the shards from the")
cmd.Println(" recovery directory after you encrypt them")
cmd.Println(" and save them to a safe location.")
cmd.Println("")
cmd.Println(
" If you lose these shards, you will not be able to recover")
cmd.Println(
" SPIKE Nexus in the unlikely event of a total system crash.")
cmd.Println("")
},
}
return recoverCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package operator
import (
"encoding/hex"
"os"
"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/crypto"
"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
// operations on SPIKE Nexus.
//
// This function creates a command that allows privileged operators with the
// 'restore' role to restore SPIKE Nexus after a system failure. The command
// accepts recovery shards interactively and initiates the restoration process.
//
// Parameters:
// - source: X.509 source for SPIFFE authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID of the caller for role-based access control.
//
// Returns:
// - *cobra.Command: A cobra command that implements the restoration
// functionality.
//
// The command performs the following operations:
// - Verifies the caller has the 'restore' role, aborting otherwise.
// - Authenticates the restoration request.
// - Prompts the user to enter a recovery shard (input is hidden for
// security).
// - Sends the shard to SPIKE Nexus to contribute to restoration.
// - Reports the status of the restoration process to the user.
//
// The command will abort with a fatal error if:
// - The caller lacks the required 'restore' role.
// - There's an error reading the recovery shard from input.
// - The API call to restore using the shard fails.
// - No status is returned from the restoration attempt.
//
// If restoration is incomplete (more shards are needed), the command displays
// the current count of collected shards and instructs the user to run the
// command again to provide additional shards.
func newOperatorRestoreCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var restoreCmd = &cobra.Command{
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
}
cmd.Println("(your input will be hidden as you paste/type it)")
cmd.Print("Enter recovery shard: ")
shard, readErr := term.ReadPassword(int(os.Stdin.Fd()))
if readErr != nil {
cmd.Println("") // newline after hidden input
cmd.PrintErrf("Error: %v\n", readErr)
return
}
api := spike.NewWithSource(source)
var shardToRestore [crypto.AES256KeySize]byte
// shard is in `spike:$id:$hex` format
shardParts := strings.SplitN(string(shard), ":", 3)
if len(shardParts) != 3 {
cmd.PrintErrln("Error: Invalid shard format.")
return
}
index := shardParts[1]
hexData := shardParts[2]
// 32 bytes encoded in hex should be 64 characters
if len(hexData) != 64 {
cmd.PrintErrf("Error: Invalid hex shard length: %d (expected 64).\n",
len(hexData))
return
}
decodedShard, decodeErr := hex.DecodeString(hexData)
// Security: Use `defer` for cleanup to ensure it happens even in
// error paths
defer func() {
mem.ClearBytes(shard)
mem.ClearBytes(decodedShard)
mem.ClearRawBytes(&shardToRestore)
}()
// Security: reset shard immediately after use.
mem.ClearBytes(shard)
if decodeErr != nil {
cmd.PrintErrln("Error: Failed to decode recovery shard.")
return
}
if len(decodedShard) != crypto.AES256KeySize {
// Security: reset decodedShard immediately after use.
mem.ClearBytes(decodedShard)
cmd.PrintErrf("Error: Invalid shard length: %d (expected %d).\n",
len(decodedShard), crypto.AES256KeySize)
return
}
for i := 0; i < crypto.AES256KeySize; i++ {
shardToRestore[i] = decodedShard[i]
}
// Security: reset decodedShard immediately after use.
mem.ClearBytes(decodedShard)
ix, atoiErr := strconv.Atoi(index)
if atoiErr != nil {
cmd.PrintErrf("Error: Invalid shard index: %s\n", index)
return
}
status, restoreErr := api.Restore(ix, &shardToRestore)
// Security: reset shardToRestore immediately after recovery.
mem.ClearRawBytes(&shardToRestore)
if restoreErr != nil {
cmd.PrintErrln("Error: Failed to communicate with SPIKE Nexus.")
return
}
if status == nil {
cmd.PrintErrln("Error: No status returned from SPIKE Nexus.")
return
}
if status.Restored {
cmd.Println("")
cmd.Println(" SPIKE is now restored and ready to use.")
cmd.Println(
" See https://spike.ist/operations/recovery/ for next steps.")
cmd.Println("")
} else {
cmd.Println("")
cmd.Println(" Shards collected: ", status.ShardsCollected)
cmd.Println(" Shards remaining: ", status.ShardsRemaining)
cmd.Println(
" Please run `spike operator restore` " +
"again to provide the remaining shards.")
cmd.Println("")
}
},
}
return restoreCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"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.
// It allows users to apply policies via the command line by specifying
// the policy name, SPIFFE ID pattern, path pattern, and permissions either
// through command line flags or by reading from a YAML file.
//
// The command uses upsert semantics - it will update an existing policy if one
// exists with the same name or create a new policy if it doesn't exist.
//
// The command requires an X509Source for SPIFFE authentication and validates
// that the system is initialized before applying a policy.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for policy application
//
// Command flags:
// - --name: Name of the policy (required if not using --file)
// - --spiffeid-pattern: SPIFFE ID regex pattern for workload matching
// (required if not using --file)
// - --path-pattern: Path regex pattern for access control (required
// if not using --file)
// - --permissions: Comma-separated list of permissions
// (required if not using --file)
// - --file: Path to YAML file containing policy configuration
//
// Valid permissions:
// - read: Permission to read secrets
// - write: Permission to create, update, or delete secrets
// - list: Permission to list resources
// - super: Administrative permissions
//
// Example usage with flags:
//
// spike policy apply \
// --name "web-service-policy" \
// --spiffeid-pattern "spiffe://example\.org/web-service/.*" \
// --path-pattern "^secrets/web/database$" \
// --permissions "read,write"
//
// Example usage with YAML file:
//
// spike policy apply --file policy.yaml
//
// Example YAML file structure:
//
// name: web-service-policy
// spiffeidPattern: ^spiffe://example\.org/web-service/.*$
// pathPattern: ^secrets/web/database$
// permissions:
// - read
// - write
//
// The command will:
// 1. Validate that all required parameters are provided (either via
// flags or file)
// 2. Validate permissions and convert to the expected format
// 3. Apply the policy using upsert semantics (create if new, update if exists)
//
// Error conditions:
// - Missing required flags (when not using --file)
// - Invalid permissions specified
// - System not initialized (requires running 'spike init' first)
// - Invalid SPIFFE ID pattern
// - Policy application failure
// - File reading errors (when using --file)
// - Invalid YAML format
func newPolicyApplyCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var (
name string
pathPattern string
SPIFFEIDPattern string
permsStr string
filePath string
)
cmd := &cobra.Command{
Use: "apply",
Short: "Apply a policy configuration",
Long: `Apply a policy that grants specific permissions to workloads.
Example using YAML file:
spike policy apply --file=policy.yaml
Example YAML file structure:
name: db-access
spiffeidPattern: ^spiffe://example\.org/service/.*$
pathPattern: ^secrets/database/production/.*$
permissions:
- read
- write
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
}
api := spike.NewWithSource(source)
var policy data.PolicySpec
// Determine if we're using file-based or flag-based input
if filePath != "" {
// Read policy from the YAML file
p, err := readPolicyFromFile(filePath)
if err != nil {
c.PrintErrf("Error reading policy file: %v\n", err)
return
}
policy = p
} else {
// Use flag-based input
p, flagErr := getPolicyFromFlags(name, SPIFFEIDPattern,
pathPattern, permsStr)
if stdout.HandleAPIError(c, flagErr) {
return
}
policy = p
}
// Convert permissions slice to comma-separated string
// for validation
ps := ""
if len(policy.Permissions) > 0 {
for i, perm := range policy.Permissions {
if i > 0 {
ps += ","
}
ps += string(perm)
}
}
// Validate permissions
permissions, permErr := validatePermissions(ps)
if stdout.HandleAPIError(c, permErr) {
return
}
// Apply policy using upsert semantics
policyErr := api.CreatePolicy(policy.Name, policy.SpiffeIDPattern,
policy.PathPattern, permissions)
if stdout.HandleAPIError(c, policyErr) {
return
}
c.Printf("Policy '%s' applied successfully\n", policy.Name)
},
}
// Define flags
cmd.Flags().StringVar(&name, "name", "",
"Policy name (required if not using --file)")
cmd.Flags().StringVar(&pathPattern, "path-pattern", "",
"Resource path regex pattern, e.g., "+
"'^secrets/database/production/.*$' (required if not using --file)")
cmd.Flags().StringVar(&SPIFFEIDPattern, "spiffeid-pattern", "",
"SPIFFE ID regex pattern, e.g., "+
"'^spiffe://example\\.org/service/.*$' (required if not using --file)")
cmd.Flags().StringVar(&permsStr, "permissions", "",
"Comma-separated permissions: read, write, list, "+
"super (required if not using --file)")
cmd.Flags().StringVar(&filePath, "file", "",
"Path to YAML file containing policy configuration")
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"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.
// It allows users to create new policies via the command line by specifying
// the policy name, SPIFFE ID pattern, path pattern, and permissions.
//
// The command requires an X509Source for SPIFFE authentication and validates
// that the system is initialized before creating a policy.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for policy creation
//
// Command flags:
// - --name: Name of the policy (required)
// - --spiffeid-pattern: SPIFFE ID regex pattern for workload matching
// (required)
// - --path-pattern: Path regex pattern for access control (required)
// - --permissions: Comma-separated list of permissions (required)
//
// Valid permissions:
// - read: Permission to read secrets
// - write: Permission to create, update, or delete secrets
// - list: Permission to list resources
// - super: Administrative permissions
//
// Example usage:
//
// spike policy create \
// --name "web-service-policy" \
// --spiffeid-pattern "^spiffe://example\.org/web-service/.*$" \
// --path-pattern "^tenants/acme/creds/.*$" \
// --permissions "read,write"
//
// The command will:
// 1. Validate that all required flags are provided
// 2. Check if the system is initialized
// 3. Validate permissions and convert to the expected format
// 4. Check if a policy with the same name already exists
// 5. Create the policy using the provided parameters
//
// Error conditions:
// - Missing required flags
// - Invalid permissions specified
// - Policy with the same name already exists
// - System not initialized (requires running 'spike init' first)
// - Invalid SPIFFE ID pattern
// - Policy creation failure
func newPolicyCreateCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var (
name string
pathPattern string
SPIFFEIDPattern string
permsStr string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a new policy",
Long: `Create a new policy that grants specific permissions to workloads.
Example:
spike policy create --name=db-access
--path-pattern="^db/.*$" --spiffeid-pattern="^spiffe://example\.org/service/.*$"
--permissions="read,write"
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
}
api := spike.NewWithSource(source)
// Check if all required flags are provided
var missingFlags []string
if name == "" {
missingFlags = append(missingFlags, "name")
}
if pathPattern == "" {
missingFlags = append(missingFlags, "path-pattern")
}
if SPIFFEIDPattern == "" {
missingFlags = append(missingFlags, "spiffeid-pattern")
}
if permsStr == "" {
missingFlags = append(missingFlags, "permissions")
}
if len(missingFlags) > 0 {
c.PrintErrln("Error: All flags are required.")
for _, flag := range missingFlags {
c.PrintErrf(" --%s is missing\n", flag)
}
return
}
// Validate permissions
permissions, err := validatePermissions(permsStr)
if stdout.HandleAPIError(c, err) {
return
}
// Check if a policy with this name already exists
exists, apiErr := checkPolicyNameExists(api, name)
if stdout.HandleAPIError(c, apiErr) {
return
}
if exists {
c.PrintErrf("Error: Policy '%s' already exists.\n", name)
return
}
// Create policy
apiErr = api.CreatePolicy(name, SPIFFEIDPattern,
pathPattern, permissions)
if stdout.HandleAPIError(c, apiErr) {
return
}
c.Println("Policy created successfully.")
},
}
// Define flags
cmd.Flags().StringVar(&name, "name", "", "Policy name (required)")
cmd.Flags().StringVar(&pathPattern, "path-pattern", "",
"Resource path regexp pattern, e.g., '^secrets/.*$' (required)")
cmd.Flags().StringVar(&SPIFFEIDPattern, "spiffeid-pattern", "",
"SPIFFE ID regexp pattern, e.g., '^spiffe://example\\.org/service/.*$' (required)")
cmd.Flags().StringVar(&permsStr, "permissions", "",
"Comma-separated permissions: read, write, list, super (required)")
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"bufio"
"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/app/spike/internal/stdout"
"github.com/spiffe/spike/app/spike/internal/trust"
)
// newPolicyDeleteCommand creates a new Cobra command for policy deletion.
// It allows users to delete existing policies by providing either the policy ID
// as a command line argument or the policy name with the --name flag.
//
// The command requires an X509Source for SPIFFE authentication and validates
// that the system is initialized before attempting to delete a policy.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for policy deletion
//
// Command usage:
//
// delete [policy-id] [flags]
//
// Arguments:
// - policy-id: The unique identifier of the policy to delete (optional
// if --name is provided)
//
// Flags:
// - --name: Policy name to look up (alternative to policy ID)
//
// Example usage:
//
// spike policy delete policy-123
// spike policy delete --name=web-service-policy
//
// The command will:
// 1. Check if the system is initialized
// 2. Get the policy ID either from arguments or by looking up the policy name
// 3. Prompt the user to confirm deletion
// 4. If confirmed, attempt to delete the policy with the specified ID
// 5. Confirm successful deletion or report any errors
//
// Error conditions:
// - Neither policy ID argument nor --name flag provided
// - Policy not found by ID or name
// - User cancels the operation
// - System not initialized (requires running 'spike init' first)
// - Insufficient permissions
// - Policy deletion failure
//
// Note: This operation cannot be undone. The policy will be permanently removed
// from the system. The command requires confirmation before proceeding.
func newPolicyDeleteCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "delete [policy-id]",
Short: "Delete a policy",
Long: `Delete a policy by ID or name.
You can provide either:
- A policy ID as an argument: spike policy delete abc123
- 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
}
api := spike.NewWithSource(source)
policyID, err := sendGetPolicyIDRequest(c, args, api)
if stdout.HandleAPIError(c, err) {
return
}
// Confirm deletion
c.Printf("Are you sure you want to "+
"delete policy with ID '%s'? (y/N): ", policyID)
reader := bufio.NewReader(os.Stdin)
confirm, _ := reader.ReadString('\n')
confirm = strings.TrimSpace(confirm)
if confirm != "y" && confirm != "Y" {
c.Println("Operation canceled.")
return
}
deleteErr := api.DeletePolicy(policyID)
if stdout.HandleAPIError(c, deleteErr) {
return
}
c.Println("Policy deleted successfully.")
},
}
addNameFlag(cmd)
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"regexp"
"strings"
"github.com/spf13/cobra"
spike "github.com/spiffe/spike-sdk-go/api"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// findPolicyByName searches for a policy with the given name and returns its
// ID. It returns an error if the policy cannot be found or if there's an issue
// with the API call.
//
// Parameters:
// - api: The SPIKE API client
// - name: The policy name to search for
//
// Returns:
// - string: The policy ID if found
// - *sdkErrors.SDKError: An error if the policy is not found or there's an
// API issue
func findPolicyByName(
api *spike.API, name string,
) (string, *sdkErrors.SDKError) {
policies, err := api.ListPolicies("", "")
if err != nil {
return "", err
}
if policies != nil {
for _, policy := range *policies {
if policy.Name == name {
return policy.ID, nil
}
}
}
failErr := sdkErrors.ErrEntityNotFound.Clone()
failErr.Msg = "no policy found with name: " + name
return "", failErr
}
const uuidRegex = `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`
func validUUID(uuid string) bool {
r := regexp.MustCompile(uuidRegex)
return r.MatchString(strings.ToLower(uuid))
}
// sendGetPolicyIDRequest gets the policy ID either from command arguments or
// the name flag.
// If args contains a policy ID, it returns that. If the name flag is provided,
// it looks up the policy by name and returns its ID. If neither is provided,
// it returns an error.
//
// Parameters:
// - cmd: The Cobra command containing the flags
// - args: Command arguments that might contain the policy ID
// - api: The SPIKE API client
//
// Returns:
// - string: The policy ID
// - *sdkErrors.SDKError: An error if the policy cannot be found or if neither
// ID nor name is provided
func sendGetPolicyIDRequest(cmd *cobra.Command,
args []string, api *spike.API,
) (string, *sdkErrors.SDKError) {
var policyID string
name, _ := cmd.Flags().GetString("name")
if len(args) > 0 {
policyID = args[0]
if !validUUID(policyID) {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "invalid policy ID: " + policyID
return "", failErr
}
} else if name != "" {
id, err := findPolicyByName(api, name)
if err != nil {
return "", err
}
policyID = id
} else {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "either policy ID as argument or --name flag is required"
return "", failErr
}
return policyID, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/spike/app/spike/internal/cmd/format"
)
// addFormatFlag adds a standardized format flag to the given command.
// Supports human/h/plain/p, json/j, and yaml/y formats.
//
// Parameters:
// - cmd: The Cobra command to add the flag to
func addFormatFlag(cmd *cobra.Command) {
format.AddFormatFlag(cmd)
}
// addNameFlag adds a name flag to the given command to allow specifying
// a policy by name instead of by ID.
//
// Parameters:
// - cmd: The Cobra command to add the flag to
func addNameFlag(cmd *cobra.Command) {
cmd.Flags().String("name", "",
"Policy name to look up (alternative to policy ID)")
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"gopkg.in/yaml.v3"
"github.com/spiffe/spike/app/spike/internal/cmd/format"
)
// formatPoliciesOutput formats the output of policy list items based on the
// format flag. It supports human/plain, json, and yaml formats. For human
// format, it creates a readable tabular representation. For JSON/YAML formats,
// it marshals the policies to the appropriate structured format.
//
// If the format flag is invalid, it returns an error message.
// If the "policies" list is empty, it returns an appropriate message based on
// the format.
//
// Parameters:
// - cmd: The Cobra command containing the format flag
// - policies: The policy list items to format (contains ID and Name only)
//
// Returns:
// - string: The formatted output or error message
func formatPoliciesOutput(
cmd *cobra.Command, policies *[]data.PolicyListItem,
) string {
outputFormat, formatErr := format.GetFormat(cmd)
if formatErr != nil {
return fmt.Sprintf("Error: %v", formatErr)
}
// Check if "policies" is nil or empty
isEmptyList := policies == nil || len(*policies) == 0
switch outputFormat {
case format.JSON:
if isEmptyList {
return "[]"
}
output, marshalErr := json.MarshalIndent(policies, "", " ")
if marshalErr != nil {
return fmt.Sprintf("Error formatting output: %v", marshalErr)
}
return string(output)
case format.YAML:
if isEmptyList {
return "[]"
}
output, marshalErr := yaml.Marshal(policies)
if marshalErr != nil {
return fmt.Sprintf("Error formatting output: %v", marshalErr)
}
return string(output)
default: // format.Human
if isEmptyList {
return "No policies found."
}
var result strings.Builder
result.WriteString("POLICIES\n========\n\n")
for _, policy := range *policies {
result.WriteString(fmt.Sprintf("ID: %s\n", policy.ID))
result.WriteString(fmt.Sprintf("Name: %s\n", policy.Name))
result.WriteString("--------\n\n")
}
return result.String()
}
}
// formatPolicy formats a single policy based on the format flag.
// It supports human/plain, json, and yaml formats.
//
// Parameters:
// - cmd: The Cobra command containing the format flag
// - policy: The policy to format
//
// Returns:
// - string: The formatted policy or error message
func formatPolicy(cmd *cobra.Command, policy *data.Policy) string {
outputFormat, formatErr := format.GetFormat(cmd)
if formatErr != nil {
return fmt.Sprintf("Error: %v", formatErr)
}
if policy == nil {
return "No policy found."
}
switch outputFormat {
case format.JSON:
output, marshalErr := json.MarshalIndent(policy, "", " ")
if marshalErr != nil {
return fmt.Sprintf("Error formatting output: %v", marshalErr)
}
return string(output)
case format.YAML:
output, marshalErr := yaml.Marshal(policy)
if marshalErr != nil {
return fmt.Sprintf("Error formatting output: %v", marshalErr)
}
return string(output)
default: // format.Human
var result strings.Builder
result.WriteString("POLICY DETAILS\n=============\n\n")
result.WriteString(fmt.Sprintf("ID: %s\n", policy.ID))
result.WriteString(fmt.Sprintf("Name: %s\n", policy.Name))
result.WriteString(fmt.Sprintf("SPIFFE ID Pattern: %s\n",
policy.SPIFFEIDPattern))
result.WriteString(fmt.Sprintf("Path Pattern: %s\n",
policy.PathPattern))
perms := make([]string, 0, len(policy.Permissions))
for _, p := range policy.Permissions {
perms = append(perms, string(p))
}
result.WriteString(fmt.Sprintf("Permissions: %s\n",
strings.Join(perms, ", ")))
result.WriteString(fmt.Sprintf("Created At: %s\n",
policy.CreatedAt.Format(time.RFC3339)))
if !policy.UpdatedAt.IsZero() {
result.WriteString(fmt.Sprintf("Updated At: %s\n",
policy.UpdatedAt.Format(time.RFC3339)))
}
return result.String()
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"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
// details. It fetches and displays the complete information about a specific
// policy by ID or name.
//
// The command requires an X509Source for SPIFFE authentication and validates
// that the system is initialized before retrieving policy information.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for policy retrieval
//
// Command usage:
//
// get [policy-id] [flags]
//
// Arguments:
// - policy-id: The unique identifier of the policy to retrieve
// (optional if --name is provided)
//
// Flags:
// - --name: Policy name to look up (alternative to policy ID)
// - --format: Output format ("human" or "json", default is "human")
//
// Example usage:
//
// spike policy get abc123
// spike policy get --name=web-service-policy
// spike policy get abc123 --format=json
//
// Example output for human format:
//
// POLICY DETAILS
// =============
//
// ID: policy-123
// Name: web-service-policy
// SPIFFE ID Pattern: ^spiffe://example\.org/web-service/.*$
// Path Pattern: ^/secrets/db/.*$
// Permissions: read, write
// Created At: 2024-01-01T00:00:00Z
// Created By: user-abc
//
// Example output for JSON format:
//
// {
// "id": "policy-123",
// "name": "web-service-policy",
// "spiffeIdPattern": "^spiffe://example\\.org/web-service/.*$",
// "pathPattern": "^tenants/demo/db$",
// "permissions": ["read", "write"],
// "createdAt": "2024-01-01T00:00:00Z",
// "createdBy": "user-abc"
// }
//
// The command will:
// 1. Check if the system is initialized
// 2. Get the policy ID either from arguments or by looking up the policy name
// 3. Retrieve the policy with the specified ID
// 4. Format the policy details based on the format flag
// 5. Display the formatted output
//
// Error conditions:
// - Neither policy ID argument nor --name flag provided
// - Policy not found by ID or name
// - Invalid format specified
// - System not initialized (requires running 'spike init' first)
// - Insufficient permissions
func newPolicyGetCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "get [policy-id]",
Short: "Get policy details",
Long: `Get detailed information about a policy by ID or name.
You can provide either:
- A policy ID as an argument: spike policy get abc123
- A policy name with the --name flag: spike policy get --name=my-policy
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
}
api := spike.NewWithSource(source)
policyID, err := sendGetPolicyIDRequest(c, args, api)
if stdout.HandleAPIError(c, err) {
return
}
policy, apiErr := api.GetPolicy(policyID)
if stdout.HandleAPIError(c, apiErr) {
return
}
if policy == nil {
c.PrintErrln("Error: Empty response from server.")
return
}
output := formatPolicy(c, policy)
c.Println(output)
},
}
addNameFlag(cmd)
addFormatFlag(cmd)
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"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.
// It retrieves and displays policies, optionally filtering by a resource path
// pattern or a SPIFFE ID pattern.
//
// The command requires an X509Source for SPIFFE authentication and validates
// that the system is initialized before listing policies.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured Cobra command for policy listing
//
// Command usage:
//
// list [--format=<format>] [--path-pattern=<pattern> | --spiffeid-pattern=<pattern>]
//
// Flags:
// - --format: Output format ("human" or "json", default is "human")
// - --path-pattern: Filter policies by a resource path pattern
// (e.g., '^secrets/.*$')
// - --spiffeid-pattern: Filter policies by a SPIFFE ID pattern
// (e.g., '^spiffe://example\.org/service/.*$')
//
// Note: --path-pattern and --spiffeid-pattern flags cannot be used together.
//
// Example usage:
//
// spike policy list
// spike policy list --format=json
// spike policy list --path-pattern="^secrets/db/.*$"
// spike policy list --spiffeid-pattern="^spiffe://example\.org/app$"
//
// Example output for human format:
//
// POLICIES
// ========
//
// ID: policy-123
// Name: web-service-policy
// SPIFFE ID Pattern: ^spiffe://example\.org/web-service/.*$
// Path Pattern: ^secrets/db/.*$
// Permissions: read, write
// Created At: 2024-01-01T00:00:00Z
// Created By: user-abc
// --------
//
// Example output for JSON format:
//
// [
// {
// "id": "policy-123",
// "name": "web-service-policy",
// "spiffeIdPattern": "^spiffe://example\.org/web-service/.*$",
// "pathPattern": "^tenants/demo/db$",
// "permissions": ["read", "write"],
// "createdAt": "2024-01-01T00:00:00Z",
// "createdBy": "user-abc"
// }
// ]
//
// The command will:
// 1. Check if the system is initialized
// 2. Retrieve existing policies based on filters
// 3. Format the policies based on the format flag
// 4. Display the formatted output
//
// Error conditions:
// - System not initialized (requires running 'spike init' first)
// - An invalid format specified
// - Using --path and --spiffeid flags together
// - Insufficient permissions
// - Policy retrieval failure
//
// Note: If no policies exist, it returns "No policies found" for human format
// or "[]" for JSON format.
func newPolicyListCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var (
pathPattern string
SPIFFEIDPattern string
)
cmd := &cobra.Command{
Use: "list",
Short: "List policies, optionally filtering by path pattern or " +
"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
}
api := spike.NewWithSource(source)
policies, err := api.ListPolicies(SPIFFEIDPattern, pathPattern)
if stdout.HandleAPIError(c, err) {
return
}
output := formatPoliciesOutput(c, policies)
c.Println(output)
},
}
cmd.Flags().StringVar(&pathPattern, "path-pattern", "",
"Resource path pattern, e.g., '^secrets/web/db$'")
cmd.Flags().StringVar(&SPIFFEIDPattern, "spiffeid-pattern", "",
"SPIFFE ID pattern, e.g., '^spiffe://example\\.org/service/finance$'")
cmd.MarkFlagsMutuallyExclusive("path-pattern", "spiffeid-pattern")
addFormatFlag(cmd)
return cmd
}
package policy
import (
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
// NewCommand creates a new top-level command for working with policies.
// It acts as a parent for all policy-related subcommands: create, list, get,
// and delete.
//
// The policy commands allow for managing access control policies that define
// which workloads can access which resources based on SPIFFE ID patterns and
// path patterns.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable. Subcommands will check for nil
// and display user-friendly error messages instead of crashing.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured top-level Cobra command for policy management
//
// Available subcommands:
// - create: Create a new policy
// - list: List all existing policies
// - get: Get details of a specific policy by ID or name
// - delete: Delete a policy by ID or name
//
// Example usage:
//
// spike policy list
// spike policy get abc123
// spike policy get --name=my-policy
// spike policy create --name=new-policy --path-pattern="^secret/.*$" \
// --spiffeid-pattern="^spiffe://example\.org/.*$" --permissions=read,write
// spike policy delete abc123
// spike policy delete --name=my-policy
//
// Each subcommand has its own set of flags and arguments. See the individual
// command documentation for details.
func NewCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "policy",
Short: "Manage policies",
Long: `Manage access control policies.
Policies control which workloads can access which secrets.
Each policy defines a set of permissions granted to workloads
matching a SPIFFE ID pattern for resources matching a path pattern.
Available subcommands:
create Create a new policy
list List all policies
get Get details of a specific policy
delete Delete a policy`,
}
// Add subcommands
cmd.AddCommand(newPolicyListCommand(source, SPIFFEID))
cmd.AddCommand(newPolicyGetCommand(source, SPIFFEID))
cmd.AddCommand(newPolicyCreateCommand(source, SPIFFEID))
cmd.AddCommand(newPolicyDeleteCommand(source, SPIFFEID))
cmd.AddCommand(newPolicyApplyCommand(source, SPIFFEID))
return cmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
"os"
"strings"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"gopkg.in/yaml.v3"
)
// readPolicyFromFile reads and parses a policy configuration from a YAML
// file. The function validates that the file exists, parses the YAML content,
// and ensures all required fields are present.
//
// The YAML file must contain the following required fields:
// - name: Policy name
// - spiffeidPattern: Regular expression pattern for SPIFFE IDs
// - pathPattern: Regular expression pattern for resource paths
// - permissions: List of permissions to grant
//
// Parameters:
// - filePath: Path to the YAML file containing the policy specification
//
// Returns:
// - data.PolicySpec: Parsed policy specification
// - *sdkErrors.SDKError: File reading, parsing, or validation errors
//
// Example YAML format:
//
// name: my-policy
// spiffeidPattern: "^spiffe://example\\.org/.*$"
// pathPattern: "^secrets/.*$"
// permissions:
// - read
// - write
func readPolicyFromFile(
filePath string,
) (data.PolicySpec, *sdkErrors.SDKError) {
var policy data.PolicySpec
// Check if the file exists:
if _, err := os.Stat(filePath); os.IsNotExist(err) {
failErr := sdkErrors.ErrFSFileOpenFailed.Clone()
failErr.Msg = "file " + filePath + " does not exist"
return policy, failErr
}
// Read file content
df, err := os.ReadFile(filePath)
if err != nil {
failErr := sdkErrors.ErrFSStreamReadFailed.Wrap(err)
failErr.Msg = "failed to read file " + filePath
return policy, failErr
}
// Parse YAML
err = yaml.Unmarshal(df, &policy)
if err != nil {
failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(err)
failErr.Msg = "failed to parse YAML file " + filePath
return policy, failErr
}
// Validate required fields
if policy.Name == "" {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "policy name is required in YAML file"
return policy, failErr
}
if policy.SpiffeIDPattern == "" {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "spiffeidPattern is required in YAML file"
return policy, failErr
}
if policy.PathPattern == "" {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "pathPattern is required in YAML file"
return policy, failErr
}
if len(policy.Permissions) == 0 {
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "permissions are required in YAML file"
return policy, failErr
}
return policy, nil
}
// getPolicyFromFlags constructs a policy specification from command-line
// flag values. The function validates that all required flags are provided
// and parses the comma-separated permissions string into a slice.
//
// All parameters are required. If any parameter is empty, the function
// returns an error listing all missing flags.
//
// Parameters:
// - name: Policy name (required)
// - SPIFFEIDPattern: Regular expression pattern for SPIFFE IDs (required)
// - pathPattern: Regular expression pattern for resource paths (required)
// - permsStr: Comma-separated list of permissions (e.g., "read,write")
//
// Returns:
// - data.PolicySpec: Constructed policy specification
// - *sdkErrors.SDKError: Validation errors if required flags are missing
//
// Example usage:
//
// policy, err := getPolicyFromFlags(
// "my-policy",
// "^spiffe://example\\.org/.*$",
// "^secrets/.*$",
// "read,write,delete",
// )
func getPolicyFromFlags(
name, SPIFFEIDPattern, pathPattern, permsStr string,
) (data.PolicySpec, *sdkErrors.SDKError) {
var policy data.PolicySpec
// Check if all required flags are provided
var missingFlags []string
if name == "" {
missingFlags = append(missingFlags, "name")
}
if pathPattern == "" {
missingFlags = append(missingFlags, "path-pattern")
}
if SPIFFEIDPattern == "" {
missingFlags = append(missingFlags, "spiffeid-pattern")
}
if permsStr == "" {
missingFlags = append(missingFlags, "permissions")
}
if len(missingFlags) > 0 {
flagList := ""
for i, flag := range missingFlags {
if i > 0 {
flagList += ", "
}
flagList += "--" + flag
}
failErr := sdkErrors.ErrDataInvalidInput.Clone()
failErr.Msg = "required flags are missing: " + flagList +
" (or use --file to read from YAML)"
return policy, failErr
}
// Convert comma-separated permissions to slice
var permissions []data.PolicyPermission
if permsStr != "" {
for _, perm := range strings.Split(permsStr, ",") {
perm = strings.TrimSpace(perm)
if perm != "" {
permissions = append(permissions, data.PolicyPermission(perm))
}
}
}
policy = data.PolicySpec{
Name: name,
SpiffeIDPattern: SPIFFEIDPattern,
PathPattern: pathPattern,
Permissions: permissions,
}
return policy, nil
}
package policy
import (
"strings"
)
// normalizePolicyOutput is a helper function that normalizes policy output
// for testing.
//
// Parameters:
// - output: the raw multi-line string produced by the policy list command
//
// Returns:
// - A normalized string with trailing whitespace removed from each line
//
// It skips non-data lines such as "POLICIES", separators (e.g. "===="),
// and blank lines.
func normalizePolicyOutput(output string) string {
lines := strings.Split(output, "\n")
var b strings.Builder
for _, raw := range lines {
line := strings.TrimSpace(raw)
// Skip the header section (POLICIES, separator, blank line, dashes)
if line == "" || line == "POLICIES" ||
strings.HasPrefix(line, "=") || strings.HasPrefix(line, "-") {
continue
}
b.WriteString(line)
b.WriteString("\n")
}
return b.String()
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package policy
import (
spike "github.com/spiffe/spike-sdk-go/api"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
)
// validatePermissions is a wrapper around validation.ValidatePermissions that
// validates policy permissions from a comma-separated string.
// See validation.ValidatePermissions for details.
var validatePermissions = validation.ValidatePermissions
// checkPolicyNameExists checks if a policy with the given name already exists.
//
// Parameters:
// - api: The SPIKE API client
// - name: The policy name to check
//
// Returns:
// - bool: true if a policy with the name exists, false otherwise
// - *sdkErrors.SDKError: An error if there is an issue with the API call
func checkPolicyNameExists(
api *spike.API, name string,
) (bool, *sdkErrors.SDKError) {
policies, err := api.ListPolicies("", "")
if err != nil {
return false, err
}
if policies != nil {
for _, policy := range *policies {
if policy.Name == name {
return true, nil
}
}
}
return false, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"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/app/spike/internal/stdout"
"github.com/spiffe/spike/app/spike/internal/trust"
)
// newSecretDeleteCommand creates and returns a new cobra.Command for deleting
// secrets. It configures a command that allows users to delete one or more
// versions of a secret at a specified path.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// The command accepts a single argument:
// - path: Location of the secret to delete
//
// Flags:
// - --versions, -v (string): Comma-separated list of version numbers to
// delete
// - "0" or empty: Deletes the current version only (default)
// - "1,2,3": Deletes specific versions
//
// Returns:
// - *cobra.Command: Configured delete command
//
// Example Usage:
//
// spike secret delete secret/pass # Deletes current version
// spike secret delete secret/pass -v 1,2,3 # Deletes specific versions
// spike secret delete secret/pass -v 0,1,2 # Deletes the current version plus 1,2
//
// The command performs trust to ensure:
// - Exactly one path argument is provided
// - Version numbers are valid non-negative integers
// - Version strings are properly formatted
func newSecretDeleteCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var deleteCmd = &cobra.Command{
Use: "delete <path>",
Short: "Delete secrets at the specified path",
Long: `Delete secrets at the specified path.
Specify versions using -v or --versions flag with comma-separated values.
Version 0 refers to the current/latest version.
If no version is specified, defaults to deleting the current version.
Examples:
spike secret delete secret/apocalyptica # Deletes current version
spike secret delete secret/apocalyptica -v 1,2,3 # Deletes specific versions
spike secret delete secret/apocalyptica -v 0,1,2
# 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
}
api := spike.NewWithSource(source)
path := args[0]
versions, _ := cmd.Flags().GetString("versions")
if !validSecretPath(path) {
cmd.PrintErrf("Error: Invalid secret path: %s\n", path)
return
}
if versions == "" {
versions = "0"
}
// Parse and validate versions
versionList := strings.Split(versions, ",")
for _, v := range versionList {
version, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
cmd.PrintErrf("Error: Invalid version number: %s\n", v)
return
}
if version < 0 {
cmd.PrintErrf("Error: Negative version number: %s\n", v)
return
}
}
var vv []int
for _, v := range versionList {
iv, err := strconv.Atoi(v)
if err == nil {
vv = append(vv, iv)
}
}
if vv == nil {
vv = []int{}
}
err := api.DeleteSecretVersions(path, vv)
if stdout.HandleAPIError(cmd, err) {
return
}
cmd.Println("OK")
},
}
deleteCmd.Flags().StringP("versions", "v", "0",
"Comma-separated list of versions to delete")
return deleteCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"encoding/json"
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"gopkg.in/yaml.v3"
"github.com/spiffe/spike/app/spike/internal/cmd/format"
"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
// secrets. It configures a command that fetches and displays secret data from a
// specified path.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Arguments:
// - path: Location of the secret to retrieve (required)
// - key: Optional specific key to retrieve from the secret
//
// Flags:
// - --version, -v (int): Specific version of the secret to retrieve
// (default 0) where 0 represents the current version
// - --format, -f (string): Output format. Valid options: human/h/plain/p,
// json/j, yaml/y (default "human")
//
// Returns:
// - *cobra.Command: Configured get command
//
// The command will:
// 1. Verify SPIKE initialization status via admin token
// 2. Retrieve the secret from the specified path and version
// 3. Display all key-value pairs in the secret's data field
//
// Error cases:
// - SPIKE not initialized: Prompts user to run 'spike init'
// - Secret not found: Displays an appropriate message
// - Read errors: Displays an error message
func newSecretGetCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var getCmd = &cobra.Command{
Use: "get <path> [key]",
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
}
api := spike.NewWithSource(source)
path := args[0]
version, _ := cmd.Flags().GetInt("version")
outputFormat, formatErr := format.GetFormat(cmd)
if formatErr != nil {
cmd.PrintErrf("Error: %v\n", formatErr)
return
}
if !validSecretPath(path) {
cmd.PrintErrf("Error: Invalid secret path: %s\n", path)
return
}
secret, err := api.GetSecretVersion(path, version)
if stdout.HandleAPIError(cmd, err) {
return
}
if secret == nil {
cmd.PrintErrln("Error: Secret not found.")
return
}
if secret.Data == nil {
cmd.PrintErrln("Error: Secret has no data.")
return
}
d := secret.Data
key := ""
if len(args) >= 2 {
key = args[1]
}
// For human format, use plain key:value output
if outputFormat == format.Human {
found := false
for k, v := range d {
if key == "" {
cmd.Printf("%s: %s\n", k, v)
found = true
} else if key == k {
cmd.Printf("%s\n", v)
found = true
break
}
}
if !found {
cmd.PrintErrln("Error: Key not found.")
}
return
}
// For structured formats (JSON/YAML)
var dataToFormat interface{}
if key == "" {
dataToFormat = d
} else {
val, exists := d[key]
if !exists {
cmd.PrintErrln("Error: Key not found.")
return
}
dataToFormat = val
}
switch outputFormat {
case format.YAML:
b, marshalErr := yaml.Marshal(dataToFormat)
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
case format.JSON:
b, marshalErr := json.MarshalIndent(dataToFormat, "", " ")
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
}
},
}
getCmd.Flags().IntP("version", "v", 0, "Specific version to retrieve")
format.AddFormatFlag(getCmd)
return getCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"encoding/json"
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"gopkg.in/yaml.v3"
"github.com/spiffe/spike/app/spike/internal/cmd/format"
"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
// secret paths. It configures a command that retrieves and displays all
// available secret paths from the system.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured list command
//
// The command will:
// 1. Make a network request to retrieve all available secret paths
// 2. Display the results based on the --format flag
// 3. Show "No secrets found" or "[]" if the system is empty
//
// Flags:
// - --format, -f (string): Output format. Valid options: human/h/plain/p,
// json/j, yaml/y (default "human")
//
// Example output (human format):
//
// - secret/path1
// - secret/path2
// - secret/path3
//
// Note: Requires an initialized SPIKE system and valid authentication
func newSecretListCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var listCmd = &cobra.Command{
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
}
outputFormat, formatErr := format.GetFormat(cmd)
if formatErr != nil {
cmd.PrintErrf("Error: %v\n", formatErr)
return
}
api := spike.NewWithSource(source)
keys, err := api.ListSecretKeys()
if stdout.HandleAPIError(cmd, err) {
return
}
isEmptyList := keys == nil || len(*keys) == 0
switch outputFormat {
case format.JSON:
if isEmptyList {
cmd.Println("[]")
return
}
output, marshalErr := json.MarshalIndent(keys, "", " ")
if marshalErr != nil {
cmd.PrintErrf("Error formatting output: %v\n", marshalErr)
return
}
cmd.Println(string(output))
case format.YAML:
if isEmptyList {
cmd.Println("[]")
return
}
output, marshalErr := yaml.Marshal(keys)
if marshalErr != nil {
cmd.PrintErrf("Error formatting output: %v\n", marshalErr)
return
}
cmd.Print(string(output))
default: // format.Human
if isEmptyList {
cmd.Println("No secrets found.")
return
}
for _, key := range *keys {
cmd.Printf("- %s\n", key)
}
}
},
}
format.AddFormatFlag(listCmd)
return listCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"encoding/json"
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"gopkg.in/yaml.v3"
"github.com/spiffe/spike/app/spike/internal/cmd/format"
"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
// retrieving secrets. It configures a command that fetches and displays secret
// data from a specified path.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// The command accepts a single argument:
// - path: Location of the secret to retrieve
//
// Flags:
// - --version, -v (int): Specific version of the secret to retrieve
// (default 0) where 0 represents the current version
// - --format, -f (string): Output format. Valid options: human/h/plain/p,
// json/j, yaml/y (default "human")
//
// Returns:
// - *cobra.Command: Configured get command
//
// The command will:
// 1. Verify SPIKE initialization status via admin token
// 2. Retrieve the secret metadata from the specified path and version
// 3. Display metadata based on the --format flag
//
// Error cases:
// - SPIKE not initialized: Prompts user to run 'spike init'
// - Secret not found: Displays an appropriate message
// - Read errors: Displays an error message
func newSecretMetadataGetCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "metadata",
Short: "Manage secret metadata",
}
var getCmd = &cobra.Command{
Use: "get <path>",
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
}
outputFormat, formatErr := format.GetFormat(cmd)
if formatErr != nil {
cmd.PrintErrf("Error: %v\n", formatErr)
return
}
api := spike.NewWithSource(source)
path := args[0]
version, _ := cmd.Flags().GetInt("version")
secret, err := api.GetSecretMetadata(path, version)
if stdout.HandleAPIError(cmd, err) {
return
}
if secret == nil {
cmd.Println("Secret not found.")
return
}
switch outputFormat {
case format.JSON:
output, marshalErr := json.MarshalIndent(secret, "", " ")
if marshalErr != nil {
cmd.PrintErrf("Error formatting output: %v\n", marshalErr)
return
}
cmd.Println(string(output))
case format.YAML:
output, marshalErr := yaml.Marshal(secret)
if marshalErr != nil {
cmd.PrintErrf("Error formatting output: %v\n", marshalErr)
return
}
cmd.Print(string(output))
default: // format.Human
printSecretResponse(cmd, secret)
}
},
}
getCmd.Flags().IntP("version", "v", 0, "Specific version to retrieve")
format.AddFormatFlag(getCmd)
cmd.AddCommand(getCmd)
return cmd
}
// \\ 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/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
// NewCommand creates a new top-level command for managing secrets
// within the SPIKE ecosystem. It acts as a parent for all secret-related
// subcommands that provide CRUD operations on secrets.
//
// Secrets in SPIKE are versioned key-value pairs stored securely in SPIKE
// Nexus. Access to secrets is controlled by policies that match SPIFFE IDs
// and resource paths. All secret operations use SPIFFE-based authentication
// to ensure only authorized workloads can access sensitive data.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable. Subcommands will check for nil
// and display user-friendly error messages instead of crashing.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured top-level Cobra command for secret management
//
// Available subcommands:
// - put: Create or update a secret
// - get: Retrieve a secret value
// - list: List all secrets (or filtered by path)
// - delete: Soft-delete a secret (can be recovered)
// - undelete: Restore a soft-deleted secret
// - metadata-get: Retrieve secret metadata without the value
//
// Example usage:
//
// spike secret put secrets/db/password value=mypassword
// spike secret get secrets/db/password
// spike secret list secrets/db
// spike secret delete secrets/db/password
// spike secret undelete secrets/db/password
// spike secret metadata-get secrets/db/password
//
// Note: Secret paths are namespace identifiers (e.g., "secrets/db/password"),
// not filesystem paths. They should not start with a forward slash.
//
// Each subcommand has its own set of flags and arguments. See the individual
// command documentation for details.
func NewCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
cmd := &cobra.Command{
Use: "secret",
Short: "Manage secrets",
}
// Add subcommands to the secret command
cmd.AddCommand(newSecretDeleteCommand(source, SPIFFEID))
cmd.AddCommand(newSecretUndeleteCommand(source, SPIFFEID))
cmd.AddCommand(newSecretListCommand(source, SPIFFEID))
cmd.AddCommand(newSecretGetCommand(source, SPIFFEID))
cmd.AddCommand(newSecretMetadataGetCommand(source, SPIFFEID))
cmd.AddCommand(newSecretPutCommand(source, SPIFFEID))
return cmd
}
package secret
import (
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spiffe/spike-sdk-go/api/entity/data"
)
// formatTime formats a time.Time value into a human-readable string using
// the format "2006-01-02 15:04:05 MST" (date, time, and timezone).
//
// Parameters:
// - t: The time value to format
//
// Returns:
// - string: Formatted time string
//
// Example output: "2024-01-15 14:30:45 UTC"
func formatTime(t time.Time) string {
return t.Format("2006-01-02 15:04:05 MST")
}
// printSecretResponse formats and prints secret metadata to stdout. The
// function displays secret versioning information including the current
// version, creation time, update time, and per-version details.
//
// The output is formatted in two sections:
//
// 1. Metadata section (if present):
// - Current version number
// - Oldest available version
// - Creation and update timestamps
// - Maximum versions configured
//
// 2. Versions section:
// - Per-version creation timestamps
// - Deletion timestamps (if soft-deleted)
//
// Parameters:
// - cmd: Cobra command for output
// - response: Secret metadata containing versioning information
//
// The function uses visual separators (dashes) to improve readability and
// outputs nothing if the metadata is empty.
func printSecretResponse(
cmd *cobra.Command, response *data.SecretMetadata,
) {
printSeparator := func() {
cmd.Println(strings.Repeat("-", 50))
}
hasMetadata := response.Metadata != (data.SecretMetaDataContent{})
rmd := response.Metadata
if hasMetadata {
cmd.Println("\nMetadata:")
printSeparator()
cmd.Printf("Current Version : %d\n", rmd.CurrentVersion)
cmd.Printf("Oldest Version : %d\n", rmd.OldestVersion)
cmd.Printf("Created Time : %s\n", formatTime(rmd.CreatedTime))
cmd.Printf("Last Updated : %s\n", formatTime(rmd.UpdatedTime))
cmd.Printf("Max Versions : %d\n", rmd.MaxVersions)
printSeparator()
}
if len(response.Versions) > 0 {
cmd.Println("\nSecret Versions:")
printSeparator()
for version, versionData := range response.Versions {
cmd.Printf("Version %d:\n", version)
cmd.Printf(" Created: %s\n", formatTime(versionData.CreatedTime))
if versionData.DeletedTime != nil {
cmd.Printf(" Deleted: %s\n",
formatTime(*versionData.DeletedTime))
}
printSeparator()
}
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"strings"
"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"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
// secrets. It configures a command that stores key-value pairs as a secret at a
// specified path.
//
// Parameters:
// - source: SPIFFE X.509 SVID source for authentication. Can be nil if the
// Workload API connection is unavailable, in which case the command will
// display an error message and return.
// - SPIFFEID: The SPIFFE ID to authenticate with
//
// Returns:
// - *cobra.Command: Configured put command
//
// Arguments:
// 1. path: Location where the secret will be stored (namespace format, no
// leading slash)
// 2. key=value pairs: One or more key-value pairs in the format "key=value"
//
// Example Usage:
//
// spike secret put secret/myapp username=admin password=secret
// spike secret put secret/config host=localhost port=8080
//
// The command execution flow:
// 1. Verify the X509 source is available (workload API connection active)
// 2. Authenticate the pilot using SPIFFE ID
// 3. Validate the secret path format
// 4. Parse all key-value pairs from arguments
// 5. Store the key-value pairs at the specified path via SPIKE API
//
// Error cases:
// - X509 source unavailable: Workload API connection lost
// - Invalid secret path: Path format validation failed
// - Invalid key-value format: Malformed pair (continues with other pairs)
// - SPIKE is not ready: Backend not initialized, prompts to wait
// - Network/API errors: Connection or storage failures
func newSecretPutCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var putCmd = &cobra.Command{
Use: "put <path> <key=value>...",
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
}
api := spike.NewWithSource(source)
path := args[0]
if !validSecretPath(path) {
cmd.PrintErrf("Error: Invalid secret path: %s\n", path)
return
}
kvPairs := args[1:]
values := make(map[string]string)
for _, kv := range kvPairs {
if !strings.Contains(kv, "=") {
cmd.PrintErrf("Error: Invalid key-value pair: %s\n", kv)
continue
}
kvs := strings.SplitN(kv, "=", 2)
values[kvs[0]] = kvs[1]
}
if len(values) == 0 {
cmd.Println("OK")
return
}
err := api.PutSecret(path, values)
if stdout.HandleAPIError(cmd, err) {
return
}
cmd.Println("OK")
},
}
return putCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"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/app/spike/internal/stdout"
"github.com/spiffe/spike/app/spike/internal/trust"
)
// newSecretUndeleteCommand creates and returns a new cobra.Command for
// restoring deleted secrets. It configures a command that allows users to
// restore one or more previously deleted versions of a secret at a specified
// path.
//
// Parameters:
// - source: X.509 source for workload API authentication
//
// The command accepts a single argument:
// - path: Location of the secret to restore
//
// Flags:
// - --versions, -v (string): Comma-separated list of version numbers to
// restore
// - "0" or empty: Restores the current version only (default)
// - "1,2,3": Restores specific versions
//
// Returns:
// - *cobra.Command: Configured undelete command
//
// Example Usage:
//
// spike secret undelete db/pwd # Restores current version
// spike secret undelete db/pwd -v 1,2,3 # Restores specific versions
// spike secret undelete db/pwd -v 0,1,2 # Restores the current version plus 1,2
//
// The command performs validation to ensure:
// - Exactly one path argument is provided
// - Version numbers are valid non-negative integers
// - Version strings are properly formatted
func newSecretUndeleteCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
var undeleteCmd = &cobra.Command{
Use: "undelete <path>",
Short: "Undelete secrets at the specified path",
Long: `Undelete secrets at the specified path.
Specify versions using -v or --versions flag with comma-separated values.
Version 0 refers to the current/latest version.
If no version is specified, defaults to undeleting the current version.
Examples:
spike secret undelete secret/ella # Undeletes current version
spike secret undelete secret/ella -v 1,2,3 # Undeletes specific versions
spike secret undelete secret/ella -v 0,1,2
# 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
}
api := spike.NewWithSource(source)
path := args[0]
if !validSecretPath(path) {
cmd.PrintErrf("Error: Invalid secret path: %s\n", path)
return
}
versions, _ := cmd.Flags().GetString("versions")
if versions == "" {
versions = "0"
}
// Parse and validate versions
versionStrs := strings.Split(versions, ",")
vv := make([]int, 0, len(versionStrs))
for _, v := range versionStrs {
version, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
cmd.PrintErrf("Error: Invalid version number: %s\n", v)
return
}
if version < 0 {
cmd.PrintErrf("Error: Negative version number: %s\n", v)
return
}
vv = append(vv, version)
}
err := api.UndeleteSecret(path, vv)
if stdout.HandleAPIError(cmd, err) {
return
}
cmd.Println("OK")
},
}
undeleteCmd.Flags().StringP("versions", "v", "0",
"Comma-separated list of versions to undelete")
return undeleteCmd
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import "regexp"
// validPath is the regular expression pattern used to validate secret path
// formats. It allows alphanumeric characters, dots, underscores, hyphens,
// slashes, and various special characters commonly used in path notation.
const validPath = `^[a-zA-Z0-9._\-/()?+*|[\]{}\\]+$`
// validSecretPath validates whether a given path conforms to the allowed
// secret path format. The path must match the validPath regular expression,
// which permits alphanumeric characters and common path separators.
//
// Parameters:
// - path: The secret path string to validate
//
// Returns:
// - true if the path is valid, according to the pattern
// - false if the path contains invalid characters or is malformed
//
// Note: This validation is performed client-side for early error detection.
// The server may perform additional validation.
func validSecretPath(path string) bool {
return regexp.MustCompile(validPath).MatchString(path)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package stdout
import (
"github.com/spf13/cobra"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
const commandGroupPolicy = "policy"
const commandGroupCipher = "cipher"
// HandleAPIError processes API errors and prints appropriate user-friendly
// messages. It detects the command group (policy, secret, cipher) from the
// Cobra command path and handles group-specific errors accordingly.
//
// Parameters:
// - c: Cobra command for output and command group detection
// - err: The error returned from an API call
//
// Returns:
// - bool: true if an error was handled, false if no error
//
// Common error types handled for all command groups:
// - ErrStateNotReady: System not initialized
// - ErrDataMarshalFailure: Request serialization failure
// - ErrDataUnmarshalFailure: Response parsing failure
// - ErrAPINotFound: Resource not found
// - ErrAPIBadRequest: Invalid request parameters
// - ErrDataInvalidInput: Input validation failure
// - ErrNetPeerConnection: Network connection failure
// - ErrAccessUnauthorized: Permission denied
// - ErrNetReadingResponseBody: Response read failure
//
// Policy-specific errors:
// - ErrEntityNotFound, ErrEntityInvalid, ErrAPIPostFailed,
// ErrEntityCreationFailed
//
// Cipher-specific errors:
// - ErrCryptoEncryptionFailed, ErrCryptoDecryptionFailed,
// ErrCryptoCipherNotAvailable, ErrCryptoInvalidEncryptionKeyLength
//
// For any unhandled error types, the function falls back to displaying
// the SDK error message directly.
//
// Usage example:
//
// secret, err := api.GetSecretVersion(path, version)
// if stdout.HandleAPIError(cmd, err) {
// return
// }
func HandleAPIError(c *cobra.Command, err *sdkErrors.SDKError) bool {
if err == nil {
return false
}
// Common errors (all command groups)
switch {
case err.Is(sdkErrors.ErrStateNotReady):
PrintNotReady()
return true
case err.Is(sdkErrors.ErrDataMarshalFailure):
c.PrintErrln("Error: Malformed request.")
return true
case err.Is(sdkErrors.ErrDataUnmarshalFailure):
c.PrintErrln("Error: Failed to parse API response.")
return true
case err.Is(sdkErrors.ErrAPINotFound):
c.PrintErrln("Error: Resource not found.")
return true
case err.Is(sdkErrors.ErrAPIBadRequest):
c.PrintErrln("Error: Invalid request.")
return true
case err.Is(sdkErrors.ErrDataInvalidInput):
c.PrintErrln("Error: Invalid input provided.")
return true
case err.Is(sdkErrors.ErrNetPeerConnection):
c.PrintErrln("Error: Failed to connect to SPIKE Nexus.")
return true
case err.Is(sdkErrors.ErrAccessUnauthorized):
c.PrintErrln("Error: Unauthorized access.")
return true
case err.Is(sdkErrors.ErrNetReadingResponseBody):
c.PrintErrln("Error: Failed to read response body.")
return true
}
// Command-group-specific errors
group := getCommandGroup(c)
switch group {
case commandGroupPolicy:
if handlePolicyError(c, err) {
return true
}
case commandGroupCipher:
if handleCipherError(c, err) {
return true
}
}
// Fallback for any unhandled errors
c.PrintErrf("Error: %v\n", err)
return true
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package stdout
import (
"strings"
"github.com/spf13/cobra"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// getCommandGroup extracts the command group from the Cobra command path.
// For example, "spike cipher encrypt" returns "cipher".
//
// Parameters:
// - c: Cobra command to extract the group from
//
// Returns:
// - string: The command group name (e.g., "cipher", "secret", "policy"),
// or an empty string if the command path has fewer than 2 parts
func getCommandGroup(c *cobra.Command) string {
parts := strings.Fields(c.CommandPath())
if len(parts) >= 2 {
return parts[1]
}
return ""
}
// handlePolicyError handles policy-specific SDK errors.
//
// Parameters:
// - c: Cobra command for error output
// - err: The SDK error to check and handle
//
// Returns:
// - bool: true if the error was a policy-specific error and was handled,
// false otherwise
func handlePolicyError(c *cobra.Command, err *sdkErrors.SDKError) bool {
switch {
case err.Is(sdkErrors.ErrEntityNotFound):
c.PrintErrln("Error: Entity not found.")
return true
case err.Is(sdkErrors.ErrEntityInvalid):
c.PrintErrln("Error: Invalid entity.")
return true
case err.Is(sdkErrors.ErrAPIPostFailed):
c.PrintErrln("Error: Operation failed.")
return true
case err.Is(sdkErrors.ErrEntityCreationFailed):
c.PrintErrln("Error: Failed to create resource.")
return true
}
return false
}
// handleCipherError handles cipher-specific SDK errors.
//
// Parameters:
// - c: Cobra command for error output
// - err: The SDK error to check and handle
//
// Returns:
// - bool: true if the error was a cipher-specific error and was handled,
// false otherwise
func handleCipherError(c *cobra.Command, err *sdkErrors.SDKError) bool {
switch {
case err.Is(sdkErrors.ErrCryptoEncryptionFailed):
c.PrintErrln("Error: Encryption operation failed.")
return true
case err.Is(sdkErrors.ErrCryptoDecryptionFailed):
c.PrintErrln("Error: Decryption operation failed.")
return true
case err.Is(sdkErrors.ErrCryptoCipherNotAvailable):
c.PrintErrln("Error: Cipher not available.")
return true
case err.Is(sdkErrors.ErrCryptoInvalidEncryptionKeyLength):
c.PrintErrln("Error: Invalid encryption key length.")
return true
}
return false
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package stdout provides utilities for printing formatted messages to
// standard output. It contains functions for displaying notification and
// status messages to users.
package stdout
import (
"fmt"
"os"
"sync"
)
// notReadyCallCount tracks how many times PrintNotReady has been called.
// This enables progressive messaging: brief on the first call, detailed on
// the following calls.
var (
notReadyCallCount int
notReadyMu sync.Mutex
)
// PrintNotReady prints a message indicating that SPIKE is not initialized.
//
// On the first call, it prints a brief message suggesting the user wait.
// On the following calls, it prints a more detailed message with
// troubleshooting steps and recovery instructions. This progressive approach
// avoids alarming users during normal startup delays while still providing
// help when there is a real problem.
func PrintNotReady() {
notReadyMu.Lock()
notReadyCallCount++
count := notReadyCallCount
notReadyMu.Unlock()
var msg string
if count == 1 {
msg = `
SPIKE is not ready yet. Please wait a moment and try again.
`
} else {
msg = `
SPIKE is not initialized.
Wait a few seconds and try again.
Also, check out SPIKE Nexus logs.
If the problem persists, you may need to
manually bootstrap via 'spike operator restore'.
Please check out https://spike.ist/ for additional
recovery and restoration information.
`
}
if _, err := fmt.Fprint(os.Stderr, msg); err != nil {
fmt.Println("failed to write to stderr: ", err.Error())
}
}
// \\ 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)
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"regexp"
"time"
"github.com/google/goexpect"
)
func generatePassword(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]"
password := make([]byte, length)
for i := range password {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
password[i] = charset[n.Int64()]
}
return string(password)
}
func main() {
password := generatePassword(20)
timeout := 2 * time.Minute
spike := "/home/volkan/Desktop/WORKSPACE/spike/spike"
// Initialize SPIKE.
child, _, err := expect.Spawn(spike+" initialization", -1)
if err != nil {
log.Fatal(err)
}
defer func(child *expect.GExpect) {
_ = child.Close()
}(child)
_, _, err = child.Expect(regexp.MustCompile("Enter admin password:"), timeout)
if err != nil {
log.Fatal(err)
}
err = child.Send(password + "\n")
if err != nil {
log.Fatal(err)
return
}
_, _, err = child.Expect(regexp.MustCompile("Confirm admin password:"), timeout)
if err != nil {
log.Fatal(err)
}
err = child.Send(password + "\n")
if err != nil {
log.Fatal(err)
return
}
_, _, err = child.Expect(regexp.MustCompile("SPIKE system initialization completed."), timeout)
if err != nil {
log.Fatal(err)
}
fmt.Printf("SPIKE initialized with password: %s\n", password)
// Log in to SPIKE
child, _, err = expect.Spawn(spike+" login", -1)
if err != nil {
log.Fatal(err)
}
defer func(child *expect.GExpect) {
_ = child.Close()
}(child)
_, _, err = child.Expect(regexp.MustCompile("Enter admin password:"), timeout)
if err != nil {
log.Fatal(err)
}
err = child.Send(password + "\n")
if err != nil {
log.Fatal(err)
return
}
_, _, err = child.Expect(regexp.MustCompile("Login successful."), timeout)
if err != nil {
log.Fatal(err)
}
// Put a secret
child, _, err = expect.Spawn(spike+" put tenants/acme/db username=root password=SPIKERocks", -1)
if err != nil {
log.Fatal(err)
}
_, _, err = child.Expect(regexp.MustCompile("OK"), timeout)
if err != nil {
log.Fatal(err)
}
// Get the secret
child, _, err = expect.Spawn(spike+" get tenants/acme/db", -1)
if err != nil {
log.Fatal(err)
}
_, _, err = child.Expect(regexp.MustCompile("password: SPIKERocks"), timeout)
if err != nil {
log.Fatal("Something went wrong!", err.Error())
}
log.Println("Everything is awesome!")
}
// \\ 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)
}
// \\ 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)
})
}
// \\ 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,
)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package out provides utility functions for application initialization output,
// including banner display and memory locking operations. These functions are
// typically called during the startup phase of SPIKE applications to provide
// consistent initialization behavior across all components.
package out
import (
"crypto/fips140"
"fmt"
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
)
// PrintBanner outputs the application banner to standard output, including
// the application name, version, log level, and FIPS 140.3 status. The banner
// is only printed if the SPIKE_BANNER_ENABLED environment variable is set to
// true.
//
// Parameters:
// - appName: The name of the application (e.g., "SPIKE Nexus")
// - appVersion: The version string of the application (e.g., "1.0.0")
func PrintBanner(appName, appVersion string) {
if !env.BannerEnabledVal() {
return
}
fmt.Printf(`
\\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
\\\\\ Copyright 2024-present SPIKE contributors.
\\\\\\\ SPDX-License-Identifier: Apache-2.0`+"\n\n"+
"%s v%s. | LOG LEVEL: %s; FIPS 140.3 Enabled: %v\n\n",
appName, appVersion, log.Level(), fips140.Enabled(),
)
}
// LogMemLock attempts to lock the application's memory to prevent sensitive
// data from being swapped to disk. It logs the result of the operation. If
// memory locking succeeds, a success message is logged. If it fails, a warning
// is logged only if SPIKE_SHOW_MEMORY_WARNING is enabled.
//
// Parameters:
// - appName: The name of the application, used as a prefix in log messages
func LogMemLock(appName string) {
err := mem.Lock()
if err == nil {
log.Info(appName, "message", "successfully locked memory")
return
}
if !env.ShowMemoryWarningVal() {
return
}
log.Info(appName, "message", "memory is not locked: please disable swap")
}
// Preamble performs standard application initialization output by printing
// the application banner and attempting to lock memory. This function should
// be called during application startup.
//
// Parameters:
// - appName: The name of the application (e.g., "SPIKE Nexus")
// - appVersion: The version string of the application (e.g., "1.0.0")
func Preamble(appName, appVersion string) {
PrintBanner(appName, appVersion)
LogMemLock(appName)
}