// \\ 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"
spike "github.com/spiffe/spike-sdk-go/api"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"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,
)
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() {
err := api.Close()
warnErr := sdkErrors.ErrFSStreamCloseFailed.Wrap(err)
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)
// 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 bootstrap 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"
"io"
"github.com/spiffe/go-spiffe/v2/workloadapi"
spike "github.com/spiffe/spike-sdk-go/api"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/retry"
"github.com/spiffe/spike-sdk-go/spiffe"
svid "github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/bootstrap/internal/state"
"github.com/spiffe/spike/internal/validation"
)
// 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 indefinitely until each share is successfully
// delivered. If a keeper fails to receive its share, the function logs a
// warning and retries. The function terminates the application if the retry
// mechanism fails unexpectedly.
//
// 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.CheckContext(ctx, fName)
// RootShares() generates the root key and splits it into shares.
// It enforces single-call semantics and will terminate if called again.
rs := state.RootShares()
for keeperID := range env.KeepersVal() {
keeperShare := state.KeeperShare(rs, keeperID)
log.Debug(fName, "message", "iterating", "keeper_id", keeperID)
_, err := retry.Forever(ctx, func() (bool, *sdkErrors.SDKError) {
log.Debug(fName, "message", "retrying", "keeper_id", keeperID)
err := api.Contribute(keeperShare, keeperID)
if err != nil {
failErr := sdkErrors.ErrAPIPostFailed.Wrap(err)
failErr.Msg = "failed to send shard: will retry"
log.WarnErr(fName, *failErr)
return false, failErr
}
return true, nil
})
// This should never happen since the above loop retries forever:
if err != nil {
failErr := sdkErrors.ErrStateInitializationFailed.Wrap(err)
failErr.Msg = "failed to send shards: will terminate"
log.FatalErr(fName, *failErr)
}
}
}
// 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.CheckContext(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)
// Encrypt the random text with the root key
rootKey := state.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)
_, _ = retry.Forever(ctx, func() (bool, *sdkErrors.SDKError) {
err := api.Verify(randomText, nonce, ciphertext)
if err != nil {
failErr := sdkErrors.ErrCryptoCipherVerificationFailed.Wrap(err)
failErr.Msg = "failed to verify initialization: will retry"
log.WarnErr(fName, *failErr)
return false, err
}
return true, nil
})
}
// 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
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package state
import (
"crypto/rand"
"fmt"
"strconv"
"github.com/cloudflare/circl/group"
shamir "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"
cipher "github.com/spiffe/spike/internal/crypto"
)
// RootShares generates a set of Shamir secret shares from a cryptographically
// secure random root key. It creates a 32-byte random seed, uses it to generate
// a root secret on the P256 elliptic curve group, and splits it into n shares
// using Shamir's Secret Sharing scheme with threshold t. The threshold t is
// set to (ShamirThreshold - 1), meaning t+1 shares are required for
// reconstruction. A deterministic reader seeded with the root key is used to
// ensure identical share generation across restarts, which is critical for
// synchronization after crashes. The function verifies that the generated
// shares can reconstruct the original secret before returning.
//
// Security behavior:
// The application will crash (via log.FatalErr) if:
// - Called more than once per process (would generate different root keys)
// - Random number generation fails
// - Root secret unmarshaling fails
// - Share reconstruction verification fails
//
// Returns:
// - []shamir.Share: The generated Shamir secret shares
func RootShares() []shamir.Share {
const fName = "rootShares"
// Ensure this function is only called once per process.
rootSharesGeneratedMu.Lock()
if rootSharesGenerated {
failErr := sdkErrors.ErrStateIntegrityCheck.Clone()
failErr.Msg = "RootShares() called more than once"
log.FatalErr(fName, *failErr)
}
rootSharesGenerated = true
rootSharesGeneratedMu.Unlock()
rootKeySeedMu.Lock()
defer rootKeySeedMu.Unlock()
if _, err := rand.Read(rootKeySeed[:]); err != nil {
failErr := sdkErrors.ErrCryptoRandomGenerationFailed.Wrap(err)
log.FatalErr(fName, *failErr)
}
// Initialize parameters
g := group.P256
t := uint(env.ShamirThresholdVal() - 1) // Need t+1 shares to reconstruct
n := uint(env.ShamirSharesVal()) // Total number of shares
// Create a secret from our 32-byte key:
rootSecret := g.NewScalar()
if err := rootSecret.UnmarshalBinary(rootKeySeed[:]); err != nil {
failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(err)
log.FatalErr(fName, *failErr)
}
// To compute identical shares, we need an identical seed for the random
// reader. Using `finalKey` for seed is secure because Shamir Secret Sharing
// algorithm's security does not depend on the random seed; it depends on
// the shards being securely kept secret.
// If we use `random.Read` instead, then synchronizing shards after Nexus
// crashes will be cumbersome and prone to edge-case failures.
reader := crypto.NewDeterministicReader(rootKeySeed[:])
ss := shamir.New(reader, t, rootSecret)
computedShares := ss.Share(n)
// Verify the generated shares can reconstruct the original secret.
// This crashes via log.FatalErr if reconstruction fails.
cipher.VerifyShamirReconstruction(rootSecret, computedShares)
return computedShares
}
// RootKey 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 RootKey() *[crypto.AES256KeySize]byte {
rootKeySeedMu.RLock()
defer rootKeySeedMu.RUnlock()
return &rootKeySeed
}
// 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"
)
// resetRootSharesForTesting resets the rootSharesGenerated flag to allow
// multiple calls to RootShares() within tests. This function should ONLY be
// used in test code to enable testing of RootShares() behavior.
//
// WARNING: This function should never be called in production code.
func resetRootSharesForTesting() {
rootSharesGeneratedMu.Lock()
rootSharesGenerated = false
rootSharesGeneratedMu.Unlock()
}
// Helper function to create test shares with known structure
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"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/keeper/internal/net"
"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.
net.Serve(appName, source)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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/predicate"
http "github.com/spiffe/spike/app/keeper/internal/route/base"
routing "github.com/spiffe/spike/internal/net"
)
// Serve initializes and starts a TLS-secured HTTP server for the given
// application.
//
// Serve uses the provided X509Source for TLS authentication and configures the
// server with the specified HTTP routes. It will listen on the port specified
// by the TLS port environment variable. If the server fails to start, it logs a
// fatal error and terminates the application.
//
// Parameters:
// - appName: A string identifier for the application, used in error messages
// - source: An X509Source that provides TLS certificates for the server.
// Must not be nil.
//
// The function does not return unless an error occurs, in which case it calls
// log.FatalLn and terminates the program.
func Serve(appName string, source *workloadapi.X509Source) {
if source == nil {
log.FatalErr(appName, *sdkErrors.ErrSPIFFENilX509Source)
}
if err := net.ServeWithPredicate(
source,
func() { routing.HandleRoute(http.Route) },
// Security: Only SPIKE Nexus and SPIKE Bootstrap
// can talk to SPIKE Keepers.
predicate.AllowKeeperPeer,
env.KeeperTLSPortVal(),
); err != nil {
log.FatalErr(appName, *err)
}
}
// \\ 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/app/keeper/internal/route/store"
"github.com/spiffe/spike/internal/journal"
"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/security/mem"
"github.com/spiffe/spike/app/keeper/internal/state"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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 {
net.Fail(reqres.ShardPutResponse{}.BadRequest(), w, http.StatusBadRequest)
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) {
net.Fail(reqres.ShardPutResponse{}.BadRequest(), w, http.StatusBadRequest)
return sdkErrors.ErrShamirEmptyShard
}
// `state.SetShard` copies the shard. We can safely reset this one at [1].
state.SetShard(request.Shard)
net.Success(reqres.ShardPutResponse{}.Success(), w)
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/spiffeid"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.ShardPutResponse](
r, w, reqres.ShardPutResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// Allow both Bootstrap (initial setup) and Nexus (periodic updates)
if !spiffeid.PeerCanTalkToKeeper(peerSPIFFEID.String()) {
net.Fail(
reqres.ShardPutResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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/security/mem"
"github.com/spiffe/spike/app/keeper/internal/state"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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) {
net.Fail(
reqres.ShardGetResponse{}.BadRequest(), w, http.StatusBadRequest,
)
return sdkErrors.ErrDataInvalidInput
}
responseBody := net.SuccessWithResponseBody(
reqres.ShardGetResponse{Shard: sh}.Success(), w,
)
// 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/spiffeid"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.ShardGetResponse](
r, w, reqres.ShardGetResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// Only SPIKE Nexus is authorized to retrieve shards from SPIKE Keeper.
if !spiffeid.IsNexus(peerSPIFFEID.String()) {
net.Fail(
reqres.ShardGetResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/nexus/internal/initialization"
"github.com/spiffe/spike/app/nexus/internal/net"
"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,
)
// Start the server:
net.Serve(appName, source)
}
// \\ 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 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", successfulKeeperShards,
"needs", env.ShamirThresholdVal(),
)
continue
}
// 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"
"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
// subsequent 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()
_, 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 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
}
keepers := env.KeepersVal()
if len(keepers) < env.ShamirSharesVal() {
failErr := *sdkErrors.ErrShamirNotEnoughShards.Clone()
failErr.Msg = "not enough keepers configured"
log.FatalErr(fName, failErr)
}
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 employs 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
}
rootSecret, rootShards := computeShares()
// 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 (
"github.com/cloudflare/circl/group"
shamir "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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
cipher "github.com/spiffe/spike/internal/crypto"
)
// computeShares generates a set of Shamir secret shares from the root key.
// The function uses a deterministic random reader seeded with the root key,
// which ensures that the same shares are always generated for a given root key.
// This deterministic behavior is crucial for the system's reliability, allowing
// shares to be recomputed as needed while maintaining consistency.
//
// Returns:
// - group.Scalar: The root secret as a P256 scalar (caller must zero after
// use)
// - []shamir.Share: The computed shares with monotonically increasing IDs
// starting from 1 (caller must zero after use)
//
// The function will log a fatal error and exit if:
// - The root key is nil or zeroed
// - The root key fails to unmarshal into a scalar
// - The generated shares fail reconstruction verification
func computeShares() (group.Scalar, []shamir.Share) {
const fName = "computeShares"
state.LockRootKey()
defer state.UnlockRootKey()
rk := state.RootKeyNoLock()
if rk == nil || mem.Zeroed32(rk) {
failErr := sdkErrors.ErrRootKeyEmpty.Clone()
log.FatalErr(fName, *failErr)
}
g := group.P256
t := uint(env.ShamirThresholdVal() - 1) // Need t+1 shares to reconstruct
n := uint(env.ShamirSharesVal()) // Total number of shares
rootSecret := g.NewScalar()
if err := rootSecret.UnmarshalBinary(rk[:]); err != nil {
failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(err)
log.FatalErr(fName, *failErr)
}
// Using the root key as the seed is secure because Shamir Secret Sharing
// security does not depend on the random seed; it depends on the shards
// being kept secret. Using a deterministic reader ensures identical shares
// are generated for the same root key, which simplifies synchronization
// after Nexus restarts.
reader := crypto.NewDeterministicReader(rk[:])
ss := shamir.New(reader, t, rootSecret)
shares := ss.Share(n)
// Verify the generated shares can reconstruct the original secret.
// This crashes via log.FatalErr if reconstruction fails.
cipher.VerifyShamirReconstruction(rootSecret, shares)
return rootSecret, shares
}
// \\ 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"
network "github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/predicate"
"github.com/spiffe/spike/internal/net"
)
// 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 := network.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"
network "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"
"github.com/spiffe/spike/internal/net"
)
// 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
}
rootSecret, rootShares := computeShares()
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 := network.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 net
import (
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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"
http "github.com/spiffe/spike/app/nexus/internal/route/base"
routing "github.com/spiffe/spike/internal/net"
)
// Serve initializes and starts a TLS-secured HTTP server for the given
// application.
//
// Serve uses the provided X509Source for TLS authentication and configures the
// server with the specified HTTP routes. It will listen on the port specified
// by the TLS port environment variable. If the server fails to start, it logs a
// fatal error and terminates the application.
//
// Parameters:
// - appName: A string identifier for the application, used in error messages
// - source: An X509Source that provides TLS certificates for the server. Can
// be nil, but if nil at startup, the function will crash with log.FatalErr
// since the server cannot operate without mTLS credentials and there is no
// retry mechanism for server initialization. This fail-fast behavior makes
// configuration or initialization problems immediately obvious to
// operators.
//
// The function does not return unless an error occurs, in which case it calls
// log.FatalErr and terminates the program.
func Serve(appName string, source *workloadapi.X509Source) {
// Fail-fast if the source is nil: server cannot operate without mTLS
if source == nil {
log.FatalErr(appName, *sdkErrors.ErrSPIFFENilX509Source)
}
if serveErr := net.Serve(
source,
func() { routing.HandleRoute(http.Route) },
env.NexusTLSPortVal(),
); serveErr != nil {
log.FatalErr(appName, *serveErr)
}
}
// \\ 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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(deleteErr, w, reqres.PolicyDeleteResponse{})
}
net.Success(reqres.PolicyDeleteResponse{}.Success(), w)
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"
cfg "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[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 {
net.Fail(
reqres.PolicyDeleteResponse{}.BadRequest(), w, http.StatusBadRequest,
)
validationErr.Msg = "invalid policy ID: " + policyID
return validationErr
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), cfg.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
net.Fail(
reqres.PolicyDeleteResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 policy
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(policyErr, w, reqres.PolicyReadResponse{})
}
net.Success(reqres.PolicyReadResponse{Policy: policy}.Success(), w)
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"
apiAuth "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[reqres.PolicyReadResponse](
r, w, reqres.PolicyReadResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
policyID := request.ID
validationErr := validation.ValidatePolicyID(policyID)
if validationErr != nil {
net.Fail(
reqres.PolicyReadResponse{}.BadRequest(), w, http.StatusBadRequest,
)
validationErr.Msg = "invalid policy ID: " + policyID
return validationErr
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), apiAuth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionRead},
)
if !allowed {
net.Fail(
reqres.PolicyReadResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(listErr, w, reqres.PolicyListResponse{})
}
net.Success(reqres.PolicyListResponse{Policies: policies}.Success(), w)
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"
cfg "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.PolicyListResponse](
r, w, reqres.PolicyListResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), cfg.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionList},
)
if !allowed {
net.Fail(
reqres.PolicyListResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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
}
name := request.Name
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
permissions := request.Permissions
policy, upsertErr := state.UpsertPolicy(data.Policy{
Name: name,
SPIFFEIDPattern: SPIFFEIDPattern,
PathPattern: pathPattern,
Permissions: permissions,
})
if upsertErr != nil {
return net.HandleError(upsertErr, w, reqres.PolicyPutResponse{})
}
net.Success(reqres.PolicyPutResponse{ID: policy.ID}.Success(), w)
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"
cfg "github.com/spiffe/spike-sdk-go/config/auth"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.PolicyPutResponse](
r, w, reqres.PolicyPutResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// Request "write" access to the ACL system for the SPIFFE ID.
allowed := state.CheckAccess(
peerSPIFFEID.String(), cfg.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
net.Fail(
reqres.PolicyPutResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
return sdkErrors.ErrAccessUnauthorized
}
name := request.Name
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
permissions := request.Permissions
if err := validation.ValidateName(name); err != nil {
net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
return sdkErrors.ErrDataInvalidInput
}
if err := validation.ValidateSPIFFEIDPattern(SPIFFEIDPattern); err != nil {
net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
return sdkErrors.ErrDataInvalidInput
}
if err := validation.ValidatePathPattern(pathPattern); err != nil {
net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
return sdkErrors.ErrDataInvalidInput
}
if err := validation.ValidatePermissions(permissions); err != nil {
net.Fail(
reqres.PolicyPutResponse{}.BadRequest(), w, http.StatusBadRequest,
)
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 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.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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"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 the 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/app/nexus/internal/state/persist"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleInternalError(
sdkErrors.ErrCryptoCipherNotAvailable, w,
reqres.BootstrapVerifyResponse{},
)
}
// Decrypt the ciphertext
plaintext, decryptErr := c.Open(nil, request.Nonce, request.Ciphertext, nil)
if decryptErr != nil {
return net.HandleInternalError(
sdkErrors.ErrCryptoDecryptionFailed, w,
reqres.BootstrapVerifyResponse{},
)
}
// Compute SHA-256 hash of plaintext
hash := sha256.Sum256(plaintext)
hashHex := hex.EncodeToString(hash[:])
net.Success(
reqres.BootstrapVerifyResponse{
Hash: hashHex,
}.Success(), w,
)
return nil
}
// \\ 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/spiffeid"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/crypto"
"github.com/spiffe/spike/internal/net"
)
// expectedNonceSize is the standard AES-GCM nonce size. See 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.BootstrapVerifyResponse](
r, w, reqres.BootstrapVerifyResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
if !spiffeid.IsBootstrap(peerSPIFFEID.String()) {
net.Fail(
reqres.BootstrapVerifyResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
return sdkErrors.ErrAccessUnauthorized
}
if len(request.Nonce) != expectedNonceSize {
net.Fail(
reqres.BootstrapVerifyResponse{}.BadRequest(), w,
http.StatusBadRequest,
)
return sdkErrors.ErrDataInvalidInput
}
// 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() {
net.Fail(
reqres.BootstrapVerifyResponse{}.BadRequest(), w,
http.StatusBadRequest,
)
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 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/internal/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 {
net.Fail(
reqres.CipherDecryptResponse{}.Internal(), w,
http.StatusInternalServerError,
)
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 {
net.Fail(errorResponse, w, http.StatusInternalServerError)
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/internal/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"
sdkSpiffeid "github.com/spiffe/spike-sdk-go/spiffeid"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
// 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 {
net.Fail(
reqres.CipherDecryptResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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/internal/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"
sdkSpiffeid "github.com/spiffe/spike-sdk-go/spiffeid"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
// 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 {
net.Fail(
reqres.CipherEncryptResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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/internal/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: Always nil (included for interface consistency)
func respondJSONDecrypt(
plaintext []byte, w http.ResponseWriter,
) *sdkErrors.SDKError {
net.Success(
reqres.CipherDecryptResponse{
Plaintext: plaintext,
}.Success(), w,
)
return nil
}
// 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:
// - error: Always nil (included for interface consistency)
func respondJSONEncrypt(
nonce, ciphertext []byte, w http.ResponseWriter,
) *sdkErrors.SDKError {
net.Success(
reqres.CipherEncryptResponse{
Version: spikeCipherVersion,
Nonce: nonce,
Ciphertext: ciphertext,
}.Success(), w,
)
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"
"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/internal/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/app/nexus/internal/state/persist"
"github.com/spiffe/spike/internal/net"
)
// 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
// - error: 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
}
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 to send in JSON mode
//
// Returns:
// - cipher.AEAD: The system cipher if available, nil otherwise
// - error: 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 {
net.Fail(errorResponse, w, http.StatusInternalServerError)
return nil, sdkErrors.ErrCryptoCipherNotAvailable
}
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/internal/auth"
"github.com/spiffe/spike/internal/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 := auth.ExtractPeerSPIFFEID[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 {
net.Fail(errorResponse, w, http.StatusBadRequest)
return sdkErrors.ErrCryptoUnsupportedCipherVersion
}
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 {
net.Fail(errorResponse, w, http.StatusBadRequest)
return sdkErrors.ErrDataInvalidInput
}
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() {
net.Fail(errorResponse, w, http.StatusBadRequest)
return sdkErrors.ErrDataInvalidInput
}
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() {
net.Fail(errorResponse, w, http.StatusBadRequest)
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/security/mem"
"github.com/spiffe/spike/app/nexus/internal/initialization/recovery"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleInternalError(
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.HandleInternalError(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.HandleInternalError(
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.HandleInternalError(
sdkErrors.ErrShamirEmptyShard, w, reqres.RecoverResponse{},
)
}
// Verify shard index is within valid range:
if idx < 1 || idx > env.ShamirSharesVal() {
return net.HandleInternalError(
sdkErrors.ErrShamirInvalidIndex, w, reqres.RecoverResponse{},
)
}
}
responseBody := net.SuccessWithResponseBody(
reqres.RecoverResponse{Shards: shards}.Success(), w,
)
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 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/spiffeid"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.RestoreResponse](
r, w, reqres.RestoreResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// We don't do policy checks as the recovery operation purely restricted to
// SPIKE Pilot.
if !spiffeid.IsPilotRecover(peerSPIFFEID.String()) {
net.Fail(
reqres.RestoreResponse{}.Unauthorized(), w, http.StatusUnauthorized,
)
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 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/security/mem"
"github.com/spiffe/spike/app/nexus/internal/initialization/recovery"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
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.
net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: 0,
Restored: restored,
},
}.Success(), w,
)
return nil
}
for _, shard := range shards {
if int(shard.ID) != request.ID {
continue
}
// Duplicate shard; acknowledge and ignore.
net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: threshold - currentShardCount,
Restored: restored,
},
}.Success(), w,
)
return nil
}
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
}
}
net.Success(
reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: threshold - currentShardCount,
Restored: restored,
},
}.Success(), w,
)
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"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 {
peerSPIFFEID, err := auth.ExtractPeerSPIFFEID[reqres.ShardGetResponse](
r, w, reqres.ShardGetResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// We don't do policy checks as the restore operation purely restricted to
// SPIKE Pilot.
if !spiffeid.IsPilotRestore(peerSPIFFEID.String()) {
net.Fail(
reqres.RestoreResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
return sdkErrors.ErrAccessUnauthorized
}
if request.ID < 1 || request.ID > env.ShamirMaxShareCountVal() {
net.Fail(
reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest,
)
return sdkErrors.ErrAPIBadRequest
}
allZero := true
for _, b := range request.Shard {
if b != 0 {
allZero = false
break
}
}
if allZero {
net.Fail(
reqres.RestoreResponse{}.BadRequest(), w, http.StatusBadRequest,
)
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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(deleteErr, w, reqres.SecretDeleteResponse{})
}
net.Success(reqres.SecretDeleteResponse{}.Success(), w)
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/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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(getErr, w, reqres.SecretGetResponse{})
}
net.Success(reqres.SecretGetResponse{
Secret: data.Secret{Data: secret},
}.Success(), w)
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/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/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[TUnauth](
r, w, unauthorizedResp,
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
// Check access permissions
allowed := state.CheckAccess(peerSPIFFEID.String(), path, permissions)
if !allowed {
net.Fail(unauthorizedResp, w, http.StatusUnauthorized)
return sdkErrors.ErrAccessUnauthorized
}
// Validate path format
pathErr := validation.ValidatePath(path)
if pathErr != nil {
net.Fail(badInputResp, w, http.StatusBadRequest)
pathErr.Msg = "invalid secret path: " + path
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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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
}
net.Success(reqres.SecretListResponse{Keys: state.ListKeys()}.Success(), w)
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/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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[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 {
net.Fail(
reqres.SecretListResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
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"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(getErr, w, reqres.SecretMetadataResponse{})
}
net.Success(toSecretMetadataSuccessResponse(rawSecret), w)
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/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/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[reqres.SecretMetadataResponse](
r, w, reqres.SecretMetadataResponse{}.Unauthorized(),
)
if alreadyResponded := err != nil; alreadyResponded {
return err
}
path := request.Path
pathErr := validation.ValidatePath(path)
if pathErr != nil {
net.Fail(
reqres.SecretMetadataResponse{}.BadRequest(), w,
http.StatusBadRequest,
)
pathErr.Msg = "invalid secret path: " + path
return pathErr
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), path,
[]data.PolicyPermission{data.PermissionRead},
)
if !allowed {
net.Fail(
reqres.SecretMetadataResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
failErr := *sdkErrors.ErrAccessUnauthorized.Clone()
failErr.Msg = "unauthorized to read secret metadata for: " + path
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 secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(upsertErr, w, reqres.SecretPutResponse{})
}
net.Success(reqres.SecretPutResponse{}.Success(), w)
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/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/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/auth"
"github.com/spiffe/spike/internal/net"
)
// 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 := auth.ExtractPeerSPIFFEID[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 {
net.Fail(
reqres.SecretPutResponse{}.BadRequest(), w,
http.StatusBadRequest,
)
pathErr.Msg = "invalid secret path: " + path
return pathErr
}
values := request.Values
for k := range values {
nameErr := validation.ValidateName(k)
if nameErr != nil {
net.Fail(
reqres.SecretPutResponse{}.BadRequest(), w,
http.StatusBadRequest,
)
nameErr.Msg = "invalid key name: " + k
return nameErr
}
}
allowed := state.CheckAccess(
peerSPIFFEID.String(), path,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
net.Fail(
reqres.SecretPutResponse{}.Unauthorized(), w,
http.StatusUnauthorized,
)
failErr := *sdkErrors.ErrAccessUnauthorized.Clone()
failErr.Msg = "unauthorized to write secret: " + path
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 secret
import (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// 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.HandleError(undeleteErr, w, reqres.SecretUndeleteResponse{})
}
net.Success(reqres.SecretUndeleteResponse{}.Success(), w)
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/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 (
"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/internal/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.CheckContext(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 err := s.createTables(ctx, db); err != nil {
closeErr := db.Close()
if closeErr != nil {
return err.Wrap(closeErr)
}
return err
}
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/app/nexus/internal/state/backend/sqlite/ddl"
"github.com/spiffe/spike/internal/validation"
)
// 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.CheckContext(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.CheckContext(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.CheckContext(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.CheckContext(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/app/nexus/internal/state/backend/sqlite/ddl"
"github.com/spiffe/spike/internal/validation"
)
// 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.CheckContext(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"
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/app/nexus/internal/state/backend/sqlite/ddl"
"github.com/spiffe/spike/internal/validation"
)
// StoreSecret stores a secret at the specified path with its metadata and
// versions. It performs the following operations atomically within a
// transaction:
// - 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.CheckContext(ctx, fName)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return sdkErrors.ErrTransactionBeginFailed.Wrap(err)
}
committed := false
defer func(tx *sql.Tx) {
if !committed {
err := tx.Rollback()
if err != nil {
failErr := *sdkErrors.ErrTransactionRollbackFailed.Clone()
log.WarnErr(fName, failErr)
}
}
}(tx)
// Update metadata
_, err = tx.ExecContext(ctx, ddl.QueryUpdateSecretMetadata,
path, secret.Metadata.CurrentVersion, secret.Metadata.OldestVersion,
secret.Metadata.CreatedTime,
secret.Metadata.UpdatedTime, secret.Metadata.MaxVersions,
)
if err != nil {
return sdkErrors.ErrEntityQueryFailed.Wrap(err)
}
// 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.CheckContext(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.CheckContext(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) {
err := rows.Close()
if err != nil {
failErr := *sdkErrors.ErrFSFileCloseFailed.Clone()
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 err := rows.Scan(&path); err != nil {
failErr := sdkErrors.ErrEntityQueryFailed.Wrap(err)
failErr.Msg = "failed to scan secret path row, skipping"
log.WarnErr(fName, *failErr)
continue
}
// Load the full secret for this path
secret, err := s.loadSecretInternal(ctx, path)
if err != nil {
failErr := sdkErrors.ErrEntityLoadFailed.Wrap(err)
failErr.Msg = "failed to load secret at path " + path + ", skipping"
log.WarnErr(fName, *failErr)
continue
}
if secret != nil {
secrets[path] = secret
}
}
if err := rows.Err(); err != nil {
return nil, sdkErrors.ErrEntityQueryFailed.Wrap(err)
}
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"
"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/app/nexus/internal/state/backend/sqlite/ddl"
"github.com/spiffe/spike/internal/validation"
)
// loadSecretInternal retrieves a secret and all its versions from the database
// for the specified path. It performs the actual database operations including
// loading metadata, fetching all versions, and decrypting the secret data.
//
// The function first queries for 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. Fetches all versions from the secrets table
// 3. Decrypts each version using the DataStore's cipher
// 4. Unmarshals JSON data into 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.CheckContext(ctx, fName)
var secret kv.Value
// Load metadata
metaErr := s.db.QueryRowContext(ctx, ddl.QuerySecretMetadata, path).Scan(
&secret.Metadata.CurrentVersion,
&secret.Metadata.OldestVersion,
&secret.Metadata.CreatedTime,
&secret.Metadata.UpdatedTime,
&secret.Metadata.MaxVersions)
if metaErr != nil {
if errors.Is(metaErr, sql.ErrNoRows) {
return nil, sdkErrors.ErrEntityNotFound
}
return nil, sdkErrors.ErrEntityLoadFailed
}
// 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 {
return nil, sdkErrors.ErrStateIntegrityCheck.Wrap(
errors.New(
"data integrity violation: current version not found",
),
)
}
}
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"
"testing"
"time"
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
"github.com/spiffe/spike/internal/config"
)
// 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 := config.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 = config.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()
// Insert metadata
_, metaErr := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata,
path, metadata.CurrentVersion, metadata.OldestVersion,
metadata.CreatedTime, metadata.UpdatedTime, metadata.MaxVersions)
if metaErr != nil {
t.Fatalf("Failed to insert metadata: %v", metaErr)
}
// 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)
}
}
}
// \\ 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.IsPilot(peerSPIFFEID) {
return true
}
policies, err := ListPolicies()
if err != nil {
log.WarnErr(fName, *sdkErrors.ErrEntityLoadFailed)
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
}
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"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"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/sqlite"
"github.com/spiffe/spike/internal/config"
)
// 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"
opts := map[backend.DatabaseConfigKey]any{}
opts[backend.KeyDataDir] = config.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/crypto"
"github.com/spiffe/spike/internal/config"
)
// cleanupSQLiteDatabase removes the existing SQLite database to ensure a clean test state
func cleanupSQLiteDatabase(t *testing.T) {
dataDir := config.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() {
if !mem.Lock() {
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 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 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, nil, 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, nil, 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 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/security/mem"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/spike/internal/trust"
"github.com/spiffe/spike/internal/config"
)
// 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 := config.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"
// addFormatFlag adds a format flag to the given command to allow specifying
// the output format (human or JSON).
//
// Parameters:
// - cmd: The Cobra command to add the flag to
func addFormatFlag(cmd *cobra.Command) {
cmd.Flags().String("format", "human",
"Output format: 'human' or 'json'")
}
// 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"
)
// formatPoliciesOutput formats the output of policies based on the format flag.
// It supports "human" (default) and "json" formats. For human format, it
// creates a readable tabular representation. For JSON format, it marshals the
// policies to indented JSON.
//
// 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 policies to format
//
// Returns:
// - string: The formatted output or error message
func formatPoliciesOutput(cmd *cobra.Command, policies *[]data.Policy) string {
format, _ := cmd.Flags().GetString("format")
// Validate format
if format != "" && format != "human" && format != "json" {
return fmt.Sprintf("Error: Invalid format '%s'."+
" Valid formats are: human, json", format)
}
// Check if "policies" is nil or empty
isEmptyList := policies == nil || len(*policies) == 0
if format == "json" {
if isEmptyList {
// Return an empty array instead of null for an empty list in JSON format
return "[]"
}
output, err := json.MarshalIndent(policies, "", " ")
if err != nil {
return fmt.Sprintf("Error formatting output: %v", err)
}
return string(output)
}
// Default human-readable format
if isEmptyList {
return "No policies found."
}
// The rest of the function remains the same:
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(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)))
}
result.WriteString("--------\n\n")
}
return result.String()
}
// formatPolicy formats a single policy based on the format flag.
// It converts the policy to a slice and reuses the formatPoliciesOutput
// function for consistent formatting.
//
// 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 {
format, _ := cmd.Flags().GetString("format")
// Validate format
if format != "" && format != "human" && format != "json" {
return fmt.Sprintf("Error: Invalid format '%s'. "+
"Valid formats are: human, json", format)
}
if policy == nil {
return "No policy found."
}
if format == "json" {
output, err := json.MarshalIndent(policy, "", " ")
if err != nil {
return fmt.Sprintf("Error formatting output: %v", err)
}
return string(output)
}
// Human-readable format for a single policy:
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
}
// \\ 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/internal/config"
)
// validatePermissions is a wrapper around config.ValidatePermissions that
// validates policy permissions from a comma-separated string.
// See config.ValidatePermissions for details.
var validatePermissions = config.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 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 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"
"slices"
"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/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: plain, p, yaml, y,
// json, j (default "plain")
//
// 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")
format, _ := cmd.Flags().GetString("format")
if !slices.Contains([]string{"plain",
"yaml", "json", "y", "p", "j"}, format) {
cmd.PrintErrf("Error: Invalid format: %s\n", format)
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
if format == "plain" || format == "p" {
found := false
for k, v := range d {
if len(args) < 2 || args[1] == "" {
cmd.Printf("%s: %s\n", k, v)
found = true
} else if args[1] == k {
cmd.Printf("%s\n", v)
found = true
break
}
}
if !found {
cmd.PrintErrln("Error: Key not found.")
}
return
}
if len(args) < 2 || args[1] == "" {
if format == "yaml" || format == "y" {
b, marshalErr := yaml.Marshal(d)
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
return
}
b, marshalErr := json.MarshalIndent(d, "", " ")
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
return
}
for k, v := range d {
if args[1] == k {
if format == "yaml" || format == "y" {
b, marshalErr := yaml.Marshal(v)
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
return
}
b, marshalErr := json.Marshal(v)
if marshalErr != nil {
cmd.PrintErrf("Error: %v\n", marshalErr)
return
}
cmd.Printf("%s\n", string(b))
return
}
}
cmd.PrintErrln("Error: Key not found.")
},
}
getCmd.Flags().IntP("version", "v", 0, "Specific version to retrieve")
getCmd.Flags().StringP("format", "f", "plain",
"Format to use. Valid options: plain, p, yaml, y, json, j")
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 (
"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"
)
// 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 in a formatted list
// 3. Show "No secrets found" if the system is empty
//
// Output format:
//
// Secrets:
// - 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
}
api := spike.NewWithSource(source)
keys, err := api.ListSecretKeys()
if stdout.HandleAPIError(cmd, err) {
return
}
if keys == nil {
cmd.Println("No secrets found.")
return
}
if len(*keys) == 0 {
cmd.Println("No secrets found.")
return
}
for _, key := range *keys {
cmd.Printf("- %s\n", key)
}
},
}
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 (
"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"
)
// 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
//
// 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 all metadata fields and secret versions
//
// 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
}
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
}
printSecretResponse(cmd, secret)
},
}
getCmd.Flags().IntP("version", "v", 0, "Specific version to retrieve")
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 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 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 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
// subsequent 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 subsequent 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"
svid "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 !svid.IsPilot(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 !svid.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 !svid.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 auth provides authentication utilities for SPIFFE-based operations
// in SPIKE. It offers functions for extracting and validating SPIFFE IDs from
// HTTP requests, enabling secure peer authentication in the SPIKE ecosystem.
package auth
import (
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/internal/net"
)
// ExtractPeerSPIFFEID extracts and validates the peer SPIFFE ID from an HTTP
// request. If the SPIFFE ID cannot be extracted or is nil, it writes an
// unauthorized response using the provided error response object and returns
// an error.
//
// This function is generic and can be used with any response type that needs
// to be returned in case of authentication failure.
//
// Parameters:
// - r *http.Request: The HTTP request containing peer SPIFFE ID
// - w http.ResponseWriter: Response writer for error responses
// - errorResponse T: The error response object to marshal and send if
// validation fails
//
// Returns:
// - *spiffeid.ID: The extracted SPIFFE ID if successful
// - *sdkErrors.SDKError: ErrAccessUnauthorized if extraction fails or ID is
// invalid, nil otherwise
//
// Example usage:
//
// peerID, err := auth.ExtractPeerSPIFFEID(
// r, w,
// reqres.ShardGetResponse{Err: data.ErrUnauthorized},
// )
// if err != nil {
// return err
// }
func ExtractPeerSPIFFEID[T any](
r *http.Request,
w http.ResponseWriter,
errorResponse T,
) (*spiffeid.ID, *sdkErrors.SDKError) {
peerSPIFFEID, err := spiffe.IDFromRequest(r)
if err != nil {
failErr := sdkErrors.ErrSPIFFEFailedToExtractX509SVID.Wrap(err)
responseBody, err := net.MarshalBodyAndRespondOnMarshalFail(
errorResponse, w,
)
if notRespondedYet := err == nil; notRespondedYet {
net.Respond(http.StatusUnauthorized, responseBody, w)
}
notAuthorizedErr := sdkErrors.ErrAccessUnauthorized.Wrap(failErr)
return nil, notAuthorizedErr
}
err = validation.ValidateSPIFFEID(peerSPIFFEID.String())
if err != nil {
failErr := sdkErrors.ErrSPIFFEInvalidSPIFFEID.Wrap(err)
responseBody, err := net.MarshalBodyAndRespondOnMarshalFail(
errorResponse, w,
)
if notRespondedYet := err == nil; notRespondedYet {
net.Respond(http.StatusUnauthorized, responseBody, w)
}
notAuthorizedErr := sdkErrors.ErrAccessUnauthorized.Wrap(failErr)
return nil, notAuthorizedErr
}
return peerSPIFFEID, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package config provides configuration-related functionalities
// for the SPIKE system, including version constants and directory
// management for storing encrypted backups and secrets securely.
package config
import (
"fmt"
"strings"
"github.com/spiffe/spike-sdk-go/api/entity/data"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// NexusDataFolder returns the path to the directory where Nexus stores
// its encrypted backup for its secrets and other data.
//
// The directory can be configured via the SPIKE_NEXUS_DATA_DIR environment
// variable. If not set or invalid, it falls back to ~/.spike/data.
// If the home directory is unavailable, it falls back to
// /tmp/.spike-$USER/data.
//
// The directory is created once on the first call and cached for the following
// calls.
//
// Returns:
// - string: The absolute path to the Nexus data directory.
func NexusDataFolder() string {
nexusDataOnce.Do(func() {
nexusDataPath = initNexusDataFolder()
})
return nexusDataPath
}
// PilotRecoveryFolder returns the path to the directory where the
// recovery shards will be stored as a result of the `spike recover`
// command.
//
// The directory can be configured via the SPIKE_PILOT_RECOVERY_DIR
// environment variable. If not set or invalid, it falls back to
// ~/.spike/recover. If the home directory is unavailable, it falls back to
// /tmp/.spike-$USER/recover.
//
// The directory is created once on the first call and cached for subsequent
// calls.
//
// Returns:
// - string: The absolute path to the Pilot recovery directory.
func PilotRecoveryFolder() string {
pilotRecoveryOnce.Do(func() {
pilotRecoveryPath = initPilotRecoveryFolder()
})
return pilotRecoveryPath
}
// ValidPermissions contains the set of valid policy permissions supported by
// the SPIKE system. These are sourced from the SDK to prevent typos.
//
// Valid permissions are:
// - 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)
var ValidPermissions = []data.PolicyPermission{
data.PermissionRead,
data.PermissionWrite,
data.PermissionList,
data.PermissionExecute,
data.PermissionSuper,
}
// validPermission checks if the given permission string is valid.
//
// Parameters:
// - perm: The permission string to validate.
//
// Returns:
// - true if the permission is found in ValidPermissions, false otherwise.
func validPermission(perm string) bool {
for _, p := range ValidPermissions {
if string(p) == perm {
return true
}
}
return false
}
// validPermissionsList returns a comma-separated string of valid permissions,
// suitable for display in error messages.
//
// Returns:
// - string: A comma-separated list of valid permissions.
func validPermissionsList() string {
perms := make([]string, len(ValidPermissions))
for i, p := range ValidPermissions {
perms[i] = string(p)
}
return strings.Join(perms, ", ")
}
// ValidatePermissions validates policy permissions from a comma-separated
// string and returns a slice of PolicyPermission values. It returns an error
// if any permission is invalid or if the string contains no valid permissions.
//
// Valid permissions are:
// - 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:
// - permsStr: Comma-separated string of permissions
// (e.g., "read,write,execute")
//
// Returns:
// - []data.PolicyPermission: Validated policy permissions
// - *sdkErrors.SDKError: An error if any permission is invalid
func ValidatePermissions(permsStr string) (
[]data.PolicyPermission, *sdkErrors.SDKError,
) {
var permissions []string
for _, p := range strings.Split(permsStr, ",") {
perm := strings.TrimSpace(p)
if perm != "" {
permissions = append(permissions, perm)
}
}
perms := make([]data.PolicyPermission, 0, len(permissions))
for _, perm := range permissions {
if !validPermission(perm) {
failErr := *sdkErrors.ErrAccessInvalidPermission.Clone()
failErr.Msg = fmt.Sprintf(
"invalid permission: '%s'. valid permissions: '%s'",
perm, validPermissionsList(),
)
return nil, &failErr
}
perms = append(perms, data.PolicyPermission(perm))
}
if len(perms) == 0 {
failErr := *sdkErrors.ErrAccessInvalidPermission.Clone()
failErr.Msg = "no valid permissions specified" +
". valid permissions are: " + validPermissionsList()
return nil, &failErr
}
return perms, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// initNexusDataFolder determines and creates the Nexus data directory.
// Called once via sync.Once from NexusDataFolder().
//
// Resolution order:
// 1. SPIKE_NEXUS_DATA_DIR environment variable (if set and valid)
// 2. ~/.spike/data (if the home directory is available)
// 3. /tmp/.spike-$USER/data (fallback)
//
// Returns:
// - string: The absolute path to the created data directory.
//
// Note: Calls log.FatalErr if directory creation fails in options 2 or 3.
func initNexusDataFolder() string {
const fName = "initNexusDataFolder"
// Option 1: Try custom directory from environment variable.
if path := tryCustomNexusDataDir(fName); path != "" {
return path
}
// Option 2: Try the home directory.
if path := tryHomeNexusDataDir(fName); path != "" {
return path
}
// Option 3: Fall back to /tmp with user isolation.
return createTempNexusDataDir(fName)
}
// tryCustomNexusDataDir attempts to use the SPIKE_NEXUS_DATA_DIR environment
// variable to create a custom data directory.
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path, or empty string if the environment
// variable is not set, invalid, or directory creation fails.
func tryCustomNexusDataDir(fName string) string {
customDir := os.Getenv(env.NexusDataDir)
if customDir == "" {
return ""
}
if validateErr := validateDataDirectory(customDir); validateErr != nil {
failErr := sdkErrors.ErrFSInvalidDirectory.Wrap(validateErr)
failErr.Msg = fmt.Sprintf(
"invalid custom data directory: %s. using default", customDir,
)
log.WarnErr(fName, *failErr)
return ""
}
dataPath := filepath.Join(customDir, spikeDataFolderName)
if mkdirErr := os.MkdirAll(dataPath, 0700); mkdirErr != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
failErr.Msg = fmt.Sprintf(
"failed to create custom data directory: %s", mkdirErr,
)
log.WarnErr(fName, *failErr)
return ""
}
return dataPath
}
// tryHomeNexusDataDir attempts to create the data directory under the user's
// home directory (~/.spike/data).
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path, or empty string if the home directory
// is not available.
//
// Note: Calls log.FatalErr if the home directory exists, but directory creation
// fails.
func tryHomeNexusDataDir(fName string) string {
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return ""
}
spikeDir := filepath.Join(homeDir, spikeHiddenFolderName)
dataPath := filepath.Join(spikeDir, spikeDataFolderName)
// 0700: restrict access to the owner only.
if mkdirErr := os.MkdirAll(dataPath, 0700); mkdirErr != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
failErr.Msg = fmt.Sprintf(
"failed to create spike data directory: %s", mkdirErr,
)
log.FatalErr(fName, *failErr)
}
return dataPath
}
// createTempNexusDataDir creates the data directory under /tmp with user
// isolation. This is the last resort fallback when neither the environment
// variable nor the home directory options are available.
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path (/tmp/.spike-$USER/data).
//
// Note: Calls log.FatalErr if directory creation fails, as this is the final
// fallback option.
func createTempNexusDataDir(fName string) string {
user := os.Getenv("USER")
if user == "" {
user = "spike"
}
tempDir := fmt.Sprintf("/tmp/.spike-%s", user)
dataPath := filepath.Join(tempDir, spikeDataFolderName)
if mkdirErr := os.MkdirAll(dataPath, 0700); mkdirErr != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
failErr.Msg = fmt.Sprintf(
"failed to create temp data directory: %s", mkdirErr,
)
log.FatalErr(fName, *failErr)
}
return dataPath
}
// initPilotRecoveryFolder determines and creates the Pilot recovery directory.
// Called once via sync.Once from PilotRecoveryFolder().
//
// Resolution order:
// 1. SPIKE_PILOT_RECOVERY_DIR environment variable (if set and valid)
// 2. ~/.spike/recover (if the home directory is available)
// 3. /tmp/.spike-$USER/recover (fallback)
//
// Returns:
// - string: The absolute path to the created recovery directory.
//
// Note: Calls log.FatalErr if directory creation fails in options 2 or 3.
func initPilotRecoveryFolder() string {
const fName = "initPilotRecoveryFolder"
// Option 1: Try custom directory from environment variable.
if path := tryCustomPilotRecoveryDir(fName); path != "" {
return path
}
// Option 2: Try the home directory.
if path := tryHomePilotRecoveryDir(fName); path != "" {
return path
}
// Option 3: Fall back to /tmp with user isolation.
return createTempPilotRecoveryDir(fName)
}
// tryCustomPilotRecoveryDir attempts to use the SPIKE_PILOT_RECOVERY_DIR
// environment variable to create a custom recovery directory.
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path, or empty string if the environment
// variable is not set, invalid, or directory creation fails.
func tryCustomPilotRecoveryDir(fName string) string {
customDir := os.Getenv(env.PilotRecoveryDir)
if customDir == "" {
return ""
}
if validateErr := validateDataDirectory(customDir); validateErr != nil {
warnErr := sdkErrors.ErrFSInvalidDirectory.Wrap(validateErr)
warnErr.Msg = "invalid custom recovery directory"
log.WarnErr(fName, *warnErr)
return ""
}
recoverPath := filepath.Join(customDir, spikeRecoveryFolderName)
if mkdirErr := os.MkdirAll(recoverPath, 0700); mkdirErr != nil {
warnErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
warnErr.Msg = "failed to create custom recovery directory"
log.WarnErr(fName, *warnErr)
return ""
}
return recoverPath
}
// tryHomePilotRecoveryDir attempts to create the recovery directory under the
// user's home directory (~/.spike/recover).
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path, or empty string if the home directory
// is not available.
//
// Note: Calls log.FatalErr if the home directory exists, but directory creation
// fails.
func tryHomePilotRecoveryDir(fName string) string {
homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return ""
}
spikeDir := filepath.Join(homeDir, spikeHiddenFolderName)
recoverPath := filepath.Join(spikeDir, spikeRecoveryFolderName)
// 0700: restrict access to the owner only.
if mkdirErr := os.MkdirAll(recoverPath, 0700); mkdirErr != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
failErr.Msg = "failed to create spike recovery directory"
log.FatalErr(fName, *failErr)
}
return recoverPath
}
// createTempPilotRecoveryDir creates the recovery directory under /tmp with
// user isolation. This is the last resort fallback when neither the environment
// variable nor the home directory options are available.
//
// Parameters:
// - fName: The caller's function name for logging purposes.
//
// Returns:
// - string: The created directory path (/tmp/.spike-$USER/recover).
//
// Note: Calls log.FatalErr if directory creation fails, as this is the final
// fallback option.
func createTempPilotRecoveryDir(fName string) string {
user := os.Getenv("USER")
if user == "" {
user = "spike"
}
tempDir := fmt.Sprintf("/tmp/.spike-%s", user)
recoverPath := filepath.Join(tempDir, spikeRecoveryFolderName)
if mkdirErr := os.MkdirAll(recoverPath, 0700); mkdirErr != nil {
failErr := sdkErrors.ErrFSDirectoryCreationFailed.Wrap(mkdirErr)
failErr.Msg = "failed to create temp recovery directory"
log.FatalErr(fName, *failErr)
}
return recoverPath
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// validateDataDirectory checks if a directory path is valid and safe to use
// for storing SPIKE data. It ensures the directory exists or can be created,
// has proper permissions, and is not in a restricted location.
//
// Parameters:
// - dir: The directory path to validate.
//
// Returns:
// - *sdkErrors.SDKError: An error if the directory is invalid, restricted,
// or cannot be accessed. Returns nil if the directory is valid.
func validateDataDirectory(dir string) *sdkErrors.SDKError {
fName := "validateDataDirectory"
if dir == "" {
failErr := sdkErrors.ErrFSInvalidDirectory.Clone()
failErr.Msg = "directory path cannot be empty"
return failErr
}
// Resolve to an absolute path
absPath, absErr := filepath.Abs(dir)
if absErr != nil {
failErr := sdkErrors.ErrFSInvalidDirectory.Clone()
failErr.Msg = fmt.Sprintf("failed to resolve directory path: %s", absErr)
return failErr
}
// Check for restricted paths
for _, restricted := range restrictedPaths {
if restricted == "/" {
// Special case: only block the exact root path, not all paths
if absPath == "/" {
failErr := sdkErrors.ErrFSInvalidDirectory.Clone()
failErr.Msg = "path is restricted for security reasons"
return failErr
}
continue
}
if absPath == restricted || strings.HasPrefix(absPath, restricted+"/") {
failErr := sdkErrors.ErrFSInvalidDirectory.Clone()
failErr.Msg = "path is restricted for security reasons"
return failErr
}
}
// Check if using /tmp without user isolation
if strings.HasPrefix(absPath, "/tmp/") && !strings.Contains(
absPath, os.Getenv("USER"),
) {
log.Warn(fName,
"message", "Using /tmp without user isolation is not recommended",
"path", absPath,
)
}
// Check if the directory exists
info, statErr := os.Stat(absPath)
if statErr != nil {
if !os.IsNotExist(statErr) {
failErr := sdkErrors.ErrFSDirectoryDoesNotExist.Clone()
failErr.Msg = fmt.Sprintf("failed to check directory: %s", statErr)
return failErr
}
// Directory doesn't exist, check if the parent exists and we can create it
parentDir := filepath.Dir(absPath)
if _, parentErr := os.Stat(parentDir); parentErr != nil {
failErr := sdkErrors.ErrFSParentDirectoryDoesNotExist.Clone()
failErr.Msg = fmt.Sprintf(
"parent directory does not exist: %s", parentErr,
)
return failErr
}
} else {
// Directory exists, check if it's actually a directory
if !info.IsDir() {
failErr := sdkErrors.ErrFSFileIsNotADirectory.Clone()
failErr.Msg = fmt.Sprintf("path is not a directory: %s", absPath)
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 crypto
import (
"github.com/cloudflare/circl/group"
shamir "github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/spike-sdk-go/config/env"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// VerifyShamirReconstruction verifies that a set of secret shares can
// correctly reconstruct the original secret. It performs this verification by
// attempting to recover the secret using the minimum required number of shares
// and comparing the result with the original secret.
//
// This function is intended for validating newly generated shares, not for
// restore operations. During a restore, the original secret is unknown, and
// successful reconstruction via secretsharing.Recover() is itself proof that
// the shards are mathematically valid.
//
// Parameters:
// - secret group.Scalar: The original secret to verify against.
// - shares []shamir.Share: The generated secret shares to verify.
//
// The function will:
// - Calculate the threshold (t) from the environment configuration.
// - Attempt to reconstruct the secret using exactly t+1 shares.
// - Compare the reconstructed secret with the original.
// - Zero out the reconstructed secret regardless of success or failure.
//
// If the verification fails, the function will:
// - Log a fatal error and exit if recovery fails.
// - Log a fatal error and exit if the recovered secret does not match the
// original.
//
// Security:
// - The reconstructed secret is always zeroed out to prevent memory leaks.
// - In case of fatal errors, the reconstructed secret is explicitly zeroed
// before logging since deferred functions will not run after log.FatalErr.
func VerifyShamirReconstruction(secret group.Scalar, shares []shamir.Share) {
const fName = "VerifyShamirReconstruction"
t := uint(env.ShamirThresholdVal() - 1) // Need t+1 shares to reconstruct
reconstructed, err := shamir.Recover(t, shares[:env.ShamirThresholdVal()])
// Security: Ensure that the secret is zeroed out if the check fails.
defer func() {
if reconstructed == nil {
return
}
reconstructed.SetUint64(0)
}()
if err != nil {
// deferred will not run in a fatal crash.
reconstructed.SetUint64(0)
failErr := sdkErrors.ErrShamirReconstructionFailed.Wrap(err)
failErr.Msg = "failed to recover root key"
log.FatalErr(fName, *failErr)
}
if !secret.IsEqual(reconstructed) {
// deferred will not run in a fatal crash.
reconstructed.SetUint64(0)
failErr := *sdkErrors.ErrShamirReconstructionFailed.Clone()
failErr.Msg = "recovered secret does not match original"
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 journal
import (
"encoding/json"
"fmt"
"net/http"
"time"
logger "github.com/spiffe/spike-sdk-go/log"
)
type AuditState string
const AuditEntryCreated AuditState = "audit-entry-created"
const AuditErrored AuditState = "audit-errored"
const AuditSuccess AuditState = "audit-success"
type AuditAction string
const AuditEnter AuditAction = "enter"
const AuditExit AuditAction = "exit"
const AuditCreate AuditAction = "create"
const AuditList AuditAction = "list"
const AuditDelete AuditAction = "delete"
const AuditRead AuditAction = "read"
const AuditUndelete AuditAction = "undelete"
const AuditFallback AuditAction = "fallback"
const AuditBlocked AuditAction = "blocked"
// AuditEntry represents a single audit log entry containing information about
// user actions within the system.
type AuditEntry struct {
// Component is the name of the component that performed the action.
Component string
// TrailID is a unique identifier for the audit trail
TrailID string
// Timestamp indicates when the audited action occurred
Timestamp time.Time
// UserID identifies the user who performed the action
UserID string
// Action describes what operation was performed
Action AuditAction
// Path is the URL path of the request
Path string
// Resource identifies the object or entity acted upon
Resource string
// SessionID links the action to a specific user session
SessionID string
// State represents the state of the resource after the action
State AuditState
// Err contains an error message if the action failed
Err string
// Duration is the time taken to process the action
Duration time.Duration
}
type AuditLogLine struct {
Timestamp time.Time `json:"time"`
AuditEntry AuditEntry `json:"audit"`
}
// Audit logs an audit entry as JSON to the standard log output.
// If JSON marshaling fails, it logs an error using the structured logger
// but continues execution.
func Audit(entry AuditEntry) {
audit := AuditLogLine{
Timestamp: time.Now(),
AuditEntry: entry,
}
body, err := json.Marshal(audit)
if err != nil {
// If you cannot audit, crashing is the best option.
logger.FatalLn("Audit",
"message", "Problem marshalling audit entry",
"err", err.Error())
return
}
fmt.Println(string(body))
}
// AuditRequest logs the details of an HTTP request and updates the audit entry
// with the specified action. It captures the HTTP method, path, and query
// parameters of the request for audit logging purposes.
//
// Parameters:
// - fName: The name of the function or component making the request
// - r: The HTTP request being audited
// - audit: A pointer to the AuditEntry to be updated
// - action: The AuditAction to be recorded in the audit entry
func AuditRequest(fName string,
r *http.Request, audit *AuditEntry, action AuditAction) {
audit.Component = fName
audit.Path = r.URL.Path
audit.Resource = r.URL.RawQuery
audit.Action = action
Audit(*audit)
}
// \\ 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.
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"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
)
// respondFallbackWithStatus writes a fallback JSON response with the given HTTP
// status code and error code. It sets appropriate headers to prevent caching.
//
// This function is used when the primary response handling fails or when a
// generic error response needs to be sent.
//
// Parameters:
// - w: The HTTP response writer
// - status: The HTTP status code to return
// - code: The error code to include in the response body
//
// Returns:
// - *sdkErrors.SDKError: An error if marshaling or writing fails,
// nil on success
func respondFallbackWithStatus(
w http.ResponseWriter, status int, code sdkErrors.ErrorCode,
) *sdkErrors.SDKError {
body, err := MarshalBodyAndRespondOnMarshalFail(
reqres.FallbackResponse{Err: code}, w,
)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
// Add cache invalidation headers
w.Header().Set(
"Cache-Control",
"no-store, no-cache, must-revalidate, private",
)
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
failErr := sdkErrors.ErrAPIInternal.Wrap(err)
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 net
import (
"net/http"
"time"
"github.com/spiffe/spike-sdk-go/crypto"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike/internal/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 (
"io"
"net/http"
)
// body reads and returns all bytes from an HTTP response body. This is a
// helper function that wraps io.ReadAll for use with HTTP responses.
//
// Parameters:
// - r: The HTTP response containing the body to read
//
// Returns:
// - []byte: The complete response body as a byte slice
// - error: Any error encountered while reading the body
//
// Note: This function does not close the response body. The caller is
// responsible for closing r.Body after calling this function.
func body(r *http.Response) ([]byte, error) {
data, readErr := io.ReadAll(r.Body)
if readErr != nil {
return nil, readErr
}
return data, nil
}
// \\ 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"
)
// readAndParseRequest reads the HTTP request body and parses it into a typed
// request struct in a single operation. This function combines
// ReadRequestBodyAndRespondOnFail and UnmarshalAndRespondOnFail to reduce
// boilerplate in route handlers.
//
// This function performs the following steps:
// 1. Reads the request body from the HTTP request
// 2. Returns ErrDataReadFailure if reading fails
// 3. Unmarshals the body into the request type
// 4. Returns ErrDataParseFailure (wrapping ErrDataUnmarshalFailure) if
// unmarshaling fails
// 5. Returns the parsed request and nil error on success
//
// Type Parameters:
// - Req: The request type to unmarshal into
// - Res: The response type for error cases
//
// Parameters:
// - w: http.ResponseWriter - The response writer for error handling
// - r: *http.Request - The incoming HTTP request
// - errorResponse: Res - A response object to send if parsing fails
//
// Returns:
// - *Req - A pointer to the parsed request struct, or nil if parsing failed
// - *sdkErrors.SDKError - ErrDataReadFailure, ErrDataParseFailure, or nil
//
// Example usage:
//
// request, err := net.readAndParseRequest[
// reqres.SecretDeleteRequest,
// reqres.SecretDeleteResponse](
// w, r,
// reqres.SecretDeleteResponse{Err: data.ErrBadInput},
// )
// if err != nil {
// return err
// }
func readAndParseRequest[Req any, Res any](
w http.ResponseWriter,
r *http.Request,
errorResponse Res,
) (*Req, *sdkErrors.SDKError) {
requestBody, readErr := ReadRequestBodyAndRespondOnFail(w, r)
if readErr != nil {
return nil, readErr
}
request, unmarshalErr := UnmarshalAndRespondOnFail[Req, Res](
requestBody, w, errorResponse,
)
if unmarshalErr != nil {
failErr := sdkErrors.ErrDataParseFailure.Wrap(unmarshalErr)
failErr.Msg = "problem parsing request body"
return nil, failErr
}
return request, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"bytes"
"io"
"net/http"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// Post performs an HTTP POST request with a JSON payload and returns the
// response body. It handles the common cases of connection errors, non-200
// status codes, and proper response body handling.
//
// Parameters:
// - client: An *http.Client used to make the request, typically
// configured with TLS settings.
// - path: The URL path to send the POST request to.
// - mr: A byte slice containing the marshaled JSON request body.
//
// Returns:
// - []byte: The response body if the request is successful.
// - *sdkErrors.SDKError: An error if any of the following occur:
// - sdkErrors.ErrAPIPostFailed if request creation fails
// - sdkErrors.ErrNetPeerConnection if connection fails or non-success
// status
// - sdkErrors.ErrAPINotFound if status is 404
// - sdkErrors.ErrAccessUnauthorized if status is 401
// - sdkErrors.ErrNetReadingResponseBody if reading response fails
//
// The function ensures proper cleanup by always attempting to close the
// response body via a deferred function. Close errors are logged but not
// returned to the caller.
//
// Example:
//
// client := &http.Client{}
// data := []byte(`{"key": "value"}`)
// response, err := Post(client, "https://api.example.com/endpoint", data)
// if err != nil {
// log.Fatalf("failed to post: %v", err)
// }
func Post(
client *http.Client, path string, mr []byte,
) ([]byte, *sdkErrors.SDKError) {
const fName = "Post"
// Create the request while preserving the mTLS client
req, reqErr := http.NewRequest("POST", path, bytes.NewBuffer(mr))
if reqErr != nil {
failErr := sdkErrors.ErrAPIPostFailed.Wrap(reqErr)
failErr.Msg = "failed to create request"
return nil, failErr
}
// Set headers
req.Header.Set("Content-Type", "application/json")
// Use the existing mTLS client to make the request
r, doErr := client.Do(req)
if doErr != nil {
failErr := sdkErrors.ErrNetPeerConnection.Wrap(doErr)
return nil, failErr
}
// Ensure the response body is always closed to prevent resource leaks
defer func(b io.ReadCloser) {
if b == nil {
return
}
if closeErr := b.Close(); closeErr != nil {
failErr := sdkErrors.ErrFSStreamCloseFailed.Wrap(closeErr)
failErr.Msg = "failed to close response body"
log.WarnErr(fName, *failErr)
}
}(r.Body)
if r.StatusCode != http.StatusOK {
if r.StatusCode == http.StatusNotFound {
return nil, sdkErrors.ErrAPINotFound
}
if r.StatusCode == http.StatusUnauthorized {
return nil, sdkErrors.ErrAccessUnauthorized
}
return nil, sdkErrors.ErrNetPeerConnection
}
b, bodyErr := body(r)
if bodyErr != nil {
failErr := sdkErrors.ErrNetReadingResponseBody.Wrap(bodyErr)
return nil, failErr
}
return b, nil
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"encoding/json"
"net/http"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/net"
)
// ReadRequestBodyAndRespondOnFail reads the entire request body from an HTTP
// request.
//
// On error, this function writes a 400 Bad Request status to the response
// writer and returns the error for propagation to the caller. If writing the
// error response fails, it returns a 500 Internal Server Error.
//
// Parameters:
// - w: http.ResponseWriter - The response writer for error handling
// - r: *http.Request - The incoming HTTP request
//
// Returns:
// - []byte: The request body as a byte slice, or nil if reading failed
// - *sdkErrors.SDKError: sdkErrors.ErrDataReadFailure if reading fails,
// nil on success
func ReadRequestBodyAndRespondOnFail(
w http.ResponseWriter, r *http.Request,
) ([]byte, *sdkErrors.SDKError) {
const fName = "ReadRequestBodyAndRespondOnFail"
body, err := net.RequestBody(r)
if err != nil {
failErr := sdkErrors.ErrDataReadFailure.Wrap(err)
failErr.Msg = "problem reading request body"
// do not send the wrapped error to the client as it may contain
// error details that an attacker can use and exploit.
failJSON, err := json.Marshal(sdkErrors.ErrDataReadFailure)
if err != nil {
// Cannot even parse a generic struct, this is an internal error.
w.WriteHeader(http.StatusInternalServerError)
_, writeErr := w.Write(failJSON)
if writeErr != nil {
// Cannot even write the error response, this is a critical error.
failErr = failErr.Wrap(writeErr)
failErr.Msg = "problem writing response"
}
log.ErrorErr(fName, *failErr)
return nil, failErr
}
w.WriteHeader(http.StatusBadRequest)
_, writeErr := w.Write(failJSON)
if writeErr != nil {
failErr = failErr.Wrap(writeErr)
failErr.Msg = "problem writing response"
// Cannot even write the error response, this is a critical error.
// We can only log the error at this point.
log.ErrorErr(fName, *failErr)
return nil, failErr
}
return nil, failErr
}
return body, nil
}
// UnmarshalAndRespondOnFail unmarshals a JSON request body into a typed
// request struct.
//
// This is a generic function that handles the common pattern of unmarshaling
// and validating incoming JSON requests. If unmarshaling fails, it sends the
// provided error response to the client with a 400 Bad Request status.
//
// Type Parameters:
// - Req: The request type to unmarshal into
// - Res: The response type for error cases
//
// Parameters:
// - requestBody: The raw JSON request body to unmarshal
// - w: The response writer for error handling
// - errorResponseForBadRequest: A response object to send if unmarshaling
// fails
//
// Returns:
// - *Req: A pointer to the unmarshaled request struct, or nil if
// unmarshaling failed
// - *sdkErrors.SDKError: ErrDataUnmarshalFailure if unmarshaling fails, or
// nil on success
//
// The function handles all error logging and response writing for the error
// case. Callers should check if the returned pointer is nil before proceeding.
func UnmarshalAndRespondOnFail[Req any, Res any](
requestBody []byte,
w http.ResponseWriter,
errorResponseForBadRequest Res,
) (*Req, *sdkErrors.SDKError) {
var request Req
if unmarshalErr := json.Unmarshal(requestBody, &request); unmarshalErr != nil {
failErr := sdkErrors.ErrDataUnmarshalFailure.Wrap(unmarshalErr)
responseBodyForBadRequest, err := MarshalBodyAndRespondOnMarshalFail(
errorResponseForBadRequest, w,
)
if noResponseSentYet := err == nil; noResponseSentYet {
Respond(http.StatusBadRequest, responseBodyForBadRequest, w)
}
// If marshal succeeded, we already responded with a 400 Bad Request with
// the errorResponseForBadRequest.
// Otherwise, if marshal failed (err != nil; very unlikely), we already
// responded with a 400 Bad Request in MarshalBodyAndRespondOnMarshalFail.
// Either way, we don't need to respond again. Just return the error.
return nil, failErr
}
// We were able to unmarshal the request successfully.
// We didn't send any failure response to the client so far.
// Return a pointer to the request to be handled by the calling site.
return &request, nil
}
// GuardFunc is a function type for request guard/validation functions.
// Guard functions validate requests and return an error if validation fails.
// They typically check authentication, authorization, and input validation.
//
// Type Parameters:
// - Req: The request type to validate
//
// Parameters:
// - request: The request to validate
// - w: http.ResponseWriter for writing error responses
// - r: *http.Request for accessing request context
//
// Returns:
// - *sdkErrors.SDKError: nil if validation passes, error otherwise
type GuardFunc[Req any] func(
Req, http.ResponseWriter, *http.Request,
) *sdkErrors.SDKError
// ReadParseAndGuard reads the HTTP request body, parses it, and executes
// a guard function in a single operation. This function combines
// readAndParseRequest with guard execution to further reduce boilerplate.
//
// This function performs the following steps:
// 1. Reads the request body from the HTTP request
// 2. Unmarshals the body into the request type
// 3. Executes the guard function for validation
// 4. Returns the parsed request and any errors
//
// Type Parameters:
// - Req: The request type to unmarshal into
// - Res: The response type for error cases
//
// Parameters:
// - w: The response writer for error handling
// - r: The incoming HTTP request
// - errorResponse: A response object to send if parsing fails
// - guard: The guard function to execute for validation
//
// Returns:
// - *Req: A pointer to the parsed request struct, or nil if any step failed
// - *sdkErrors.SDKError: ErrDataReadFailure, ErrDataParseFailure, or error
// from the guard function
//
// Example usage:
//
// request, err := net.ReadParseAndGuard[
// reqres.ShardPutRequest,
// reqres.ShardPutResponse](
// w, r,
// reqres.ShardPutResponse{Err: data.ErrBadInput},
// guardShardPutRequest,
// )
// if err != nil {
// return err
// }
func ReadParseAndGuard[Req any, Res any](
w http.ResponseWriter, r *http.Request, errorResponse Res,
guard GuardFunc[Req],
) (*Req, *sdkErrors.SDKError) {
request, err := readAndParseRequest[Req, Res](w, r, errorResponse)
if err != nil {
return nil, err
}
if err = guard(*request, w, r); err != nil {
return nil, err
}
return request, nil
}
// Fail sends an error response to the client.
//
// This function marshals the client response and sends it with the specified
// HTTP status code. It does not return a value; callers should return their
// own error after calling this function.
//
// Type Parameters:
// - T: The response type to send to the client (e.g.,
// reqres.ShardPutBadInput)
//
// Parameters:
// - clientResponse: The response object to send to the client
// - w: The HTTP response writer for error responses
// - statusCode: The HTTP status code to send (e.g., http.StatusBadRequest)
//
// Example usage:
//
// if request.Shard == nil {
// net.Fail(reqres.ShardPutBadInput, w, http.StatusBadRequest)
// return errors.ErrInvalidInput
// }
func Fail[T any](
clientResponse T,
w http.ResponseWriter,
statusCode int,
) {
responseBody, marshalErr := MarshalBodyAndRespondOnMarshalFail(
clientResponse, w,
)
if notRespondedYet := marshalErr == nil; notRespondedYet {
Respond(statusCode, responseBody, w)
}
}
// Success sends a success response with HTTP 200 OK.
//
// This is a convenience wrapper around Fail that sends a 200 OK status.
// It maintains semantic clarity by using the name "Success" rather than
// calling Fail directly at call sites.
//
// Type Parameters:
// - T: The response type to send to the client (e.g.,
// reqres.ShardPutSuccess)
//
// Parameters:
// - clientResponse: The response object to send to the client
// - w: The HTTP response writer
//
// Example usage:
//
// state.SetShard(request.Shard)
// net.Success(reqres.ShardPutSuccess, w)
// return nil
func Success[T any](clientResponse T, w http.ResponseWriter) {
Fail(clientResponse, w, http.StatusOK)
}
// SuccessWithResponseBody sends a success response with HTTP 200 OK and
// returns the response body for cleanup.
//
// This variant is used when the response body needs to be explicitly cleared
// from memory for security reasons, such as when returning sensitive
// cryptographic data. The caller is responsible for clearing the returned
// byte slice.
//
// Type Parameters:
// - T: The response type to send to the client (e.g.,
// reqres.ShardGetResponse)
//
// Parameters:
// - clientResponse: The response object to send to the client
// - w: The HTTP response writer
//
// Returns:
// - []byte: The marshaled response body that should be cleared for security
//
// Example usage:
//
// responseBody := net.SuccessWithResponseBody(
// reqres.ShardGetResponse{Shard: sh}.Success(), w,
// )
// defer func() {
// mem.ClearBytes(responseBody)
// }()
// return nil
func SuccessWithResponseBody[T any](
clientResponse T, w http.ResponseWriter,
) []byte {
responseBody, marshalErr := MarshalBodyAndRespondOnMarshalFail(
clientResponse, w,
)
if alreadyResponded := marshalErr != nil; alreadyResponded {
// Headers already sent. Just return the response body.
return responseBody
}
Respond(http.StatusOK, responseBody, w)
return responseBody
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"encoding/json"
"net/http"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/internal/journal"
)
// MarshalBodyAndRespondOnMarshalFail serializes a response object to JSON and
// handles error cases.
//
// This function attempts to marshal the provided response object to JSON bytes.
// If marshaling fails, it sends a 500 Internal Server Error response to the
// client and returns nil. The function handles all error logging and response
// writing for the error case.
//
// Parameters:
// - res: any - The response object to marshal to JSON
// - w: http.ResponseWriter - The response writer for error handling
//
// Returns:
// - []byte: The marshaled JSON bytes, or nil if marshaling failed
// - *sdkErrors.SDKError: sdkErrors.ErrAPIInternal if marshaling failed,
// nil otherwise
func MarshalBodyAndRespondOnMarshalFail(
res any, w http.ResponseWriter,
) ([]byte, *sdkErrors.SDKError) {
const fName = "MarshalBodyAndRespondOnMarshalFail"
body, err := json.Marshal(res)
// Since this function is typically called with sentinel error values,
// this error should, typically, never happen.
// That's why, instead of sending a "marshal failure" sentinel error,
// we return an internal sentinel error (sdkErrors.ErrAPIInternal)
if err != nil {
// Chain an error for detailed internal logging.
failErr := *sdkErrors.ErrAPIInternal.Clone()
failErr.Msg = "problem generating response"
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
internalErrJSON, marshalErr := json.Marshal(failErr)
// Add extra info "after" marshaling to avoid leaking internal error details
wrappedErr := failErr.Wrap(err)
if marshalErr != nil {
wrappedErr = wrappedErr.Wrap(marshalErr)
// Cannot marshal; try a generic message instead.
internalErrJSON = []byte(`{"error":"internal server error"}`)
}
_, err = w.Write(internalErrJSON)
if err != nil {
wrappedErr = wrappedErr.Wrap(err)
// At this point, we cannot respond. So there is not much to send.
// We cannot even send a generic error message.
// We can only log the error.
}
// Log the chained error.
log.ErrorErr(fName, *wrappedErr)
return nil, wrappedErr
}
// body marshaled successfully
return body, nil
}
// Respond writes a JSON response with the specified status code and body.
//
// This function sets the Content-Type header to application/json, adds cache
// invalidation headers (Cache-Control, Pragma, Expires), writes the provided
// status code, and sends the response body. Any errors during writing are
// logged but not returned to the caller.
//
// Parameters:
// - statusCode: int - The HTTP status code to send
// - body: []byte - The pre-marshaled JSON response body
// - w: http.ResponseWriter - The response writer to use
func Respond(statusCode int, body []byte, w http.ResponseWriter) {
const fName = "Respond"
w.Header().Set("Content-Type", "application/json")
// Add cache invalidation headers
w.Header().Set(
"Cache-Control",
"no-store, no-cache, must-revalidate, private",
)
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.WriteHeader(statusCode)
_, err := w.Write(body)
if err != nil {
// At this point, we cannot respond. So there is not much to send
// back to the client. We can only log the error.
// This should rarely, if ever, happen.
failErr := sdkErrors.ErrAPIInternal.Wrap(err)
log.ErrorErr(fName, *failErr)
}
}
// 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 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 respondFallbackWithStatus(
w, http.StatusServiceUnavailable, sdkErrors.ErrStateNotReady.Code,
)
}
// ErrorResponder defines an interface for response types that can generate
// standard error responses. All SDK response types in the reqres package
// implement this interface through their NotFound() and Internal() methods.
type ErrorResponder[T any] interface {
NotFound() T
Internal() T
}
// HandleError processes errors from state operations (database, storage) and
// sends appropriate HTTP responses. It uses generics to work with any response
// type that implements the ErrorResponder interface.
//
// Use this function in route handlers after state operations (Get, Put, Delete,
// List, etc.) that may return "not found" or internal errors. Do NOT use this
// for authentication/authorization or input validation errors in guard/intercept
// functions; those have different semantics (400 Bad Request, 401 Unauthorized)
// that don't map to the 404/500 distinction this function provides, so they
// should use net.Fail directly.
//
// The function distinguishes between two types of errors:
// - sdkErrors.ErrEntityNotFound: Returns HTTP 404 Not Found when the
// requested resource does not exist
// - Other errors: Returns HTTP 500 Internal Server Error for backend or
// server-side failures
//
// Parameters:
// - err: The error that occurred during the state operation
// - w: The HTTP response writer for sending error responses
// - response: A zero-value response instance used to generate error responses
//
// Returns:
// - *sdkErrors.SDKError: The error that was passed in (for chaining),
// or nil if err was nil
//
// Example usage:
//
// // In a route handler after a state operation:
// if err != nil {
// return net.HandleError(err, w, reqres.SecretGetResponse{})
// }
//
// // In guard/intercept functions, use net.Fail directly instead:
// if !authorized {
// net.Fail(response.Unauthorized(), w, http.StatusUnauthorized)
// return sdkErrors.ErrAccessUnauthorized
// }
func HandleError[T ErrorResponder[T]](
err *sdkErrors.SDKError, w http.ResponseWriter, response T,
) *sdkErrors.SDKError {
if err == nil {
return nil
}
if err.Is(sdkErrors.ErrEntityNotFound) {
Fail(response.NotFound(), w, http.StatusNotFound)
return err
}
// Backend or other server-side failure
Fail(response.Internal(), w, http.StatusInternalServerError)
return err
}
// InternalErrorResponder defines an interface for response types that can
// generate internal error responses. This is a subset of ErrorResponder for
// cases where only internal errors are possible (no "not found" scenario).
type InternalErrorResponder[T any] interface {
Internal() T
}
// HandleInternalError sends an HTTP 500 Internal Server Error response and
// returns the provided SDK error. Use this for operations where the only
// possible error is an internal/server error (no "not found" case), such as
// cryptographic operations, Shamir secret sharing validation, or system
// initialization checks.
//
// Like HandleError, this is intended for route handlers after state or system
// operations. Do NOT use this for authentication/authorization or input
// validation errors in guard/intercept functions; those have different semantics
// (400 Bad Request, 401 Unauthorized) that this function doesn't handle, so they
// should use net.Fail directly.
//
// Parameters:
// - err: The SDK error that occurred
// - w: The HTTP response writer for sending error responses
// - response: A zero-value response instance used to generate the error
//
// Returns:
// - *sdkErrors.SDKError: The error that was passed in
//
// Example usage:
//
// if cipher == nil {
// return net.HandleInternalError(
// sdkErrors.ErrCryptoCipherNotAvailable, w,
// reqres.BootstrapVerifyResponse{},
// )
// }
func HandleInternalError[T InternalErrorResponder[T]](
err *sdkErrors.SDKError, w http.ResponseWriter, response T,
) *sdkErrors.SDKError {
Fail(response.Internal(), w, http.StatusInternalServerError)
return err
}
// \\ 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) {
if mem.Lock() {
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)
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package validation
import (
"context"
sdkErrors "github.com/spiffe/spike-sdk-go/errors"
"github.com/spiffe/spike-sdk-go/log"
)
// CheckContext checks if the provided context is nil and terminates the
// program if so.
//
// This function is used to ensure that all operations requiring a context
// receive a valid one. A nil context indicates a programming error that
// should never occur in production, so the function terminates the program
// immediately via log.FatalErr.
//
// Parameters:
// - ctx: The context to validate
// - fName: The calling function name for logging purposes
func CheckContext(ctx context.Context, fName string) {
if ctx == nil {
failErr := *sdkErrors.ErrNilContext.Clone()
log.FatalErr(fName, failErr)
}
}