// \\ 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"
"fmt"
"time"
"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/env"
"github.com/spiffe/spike/app/bootstrap/internal/lifecycle"
"github.com/spiffe/spike/app/bootstrap/internal/net"
"github.com/spiffe/spike/app/bootstrap/internal/state"
"github.com/spiffe/spike/app/bootstrap/internal/url"
"github.com/spiffe/spike/internal/config"
)
func main() {
const fName = "bootstrap.main"
log.Log().Info(fName, "message",
"Starting SPIKE bootstrap...",
"version", config.BootstrapVersion,
)
init := flag.Bool("init", false, "Initialize the bootstrap module")
flag.Parse()
if !*init {
fmt.Println("")
fmt.Println("Usage: bootstrap -init")
fmt.Println("")
log.FatalLn(fName, "message", "Invalid command line arguments")
return
}
skip := !lifecycle.ShouldBootstrap() // Kubernetes or bare-metal check.
if skip {
log.Log().Info(fName,
"message", "Skipping bootstrap.",
)
fmt.Println("Bootstrap skipped. Check the logs for more information.")
return
}
src := net.Source()
defer spiffe.CloseSource(src)
sv, err := src.GetX509SVID()
if err != nil {
log.FatalLn(fName,
"message", "Failed to get X.509 SVID",
"err", err.Error())
log.FatalLn(fName, "message", "Failed to acquire SVID")
return
}
if !svid.IsBootstrap(sv.ID.String()) {
log.Log().Error(
"Authenticate: You need a 'bootstrap' SPIFFE ID to use this command.",
)
log.FatalLn(fName, "message", "Command not authorized")
return
}
log.Log().Info(
fName, "FIPS 140.3 enabled", fips140.Enabled(),
)
log.Log().Info(
fName, "message", "Sending shards to SPIKE Keeper instances...",
)
ctx := context.Background()
for keeperID, keeperAPIRoot := range env.Keepers() {
log.Log().Info(fName, "keeper ID", keeperID)
_, err := retry.Do(ctx, func() (bool, error) {
log.Log().Info(fName, "message", "retry:"+time.Now().String())
err := net.Post(
net.MTLSClient(src),
url.KeeperEndpoint(keeperAPIRoot),
net.Payload(
state.KeeperShare(
state.RootShares(), keeperID),
keeperID,
),
keeperID,
)
if err != nil {
log.Log().Warn(fName, "message", "Failed to send shard. Will retry.")
return false, err
}
log.Log().Info(fName, "message", "Shard sent successfully.")
return true, nil
},
retry.WithBackOffOptions(
retry.WithMaxInterval(60*time.Second), // TODO: to env vars.
retry.WithMaxElapsedTime(0), // Retry forever.
),
)
// This should never happen since the above loop retries forever:
if err != nil {
log.FatalLn(fName, "message", "Initialization failed", "err", err)
}
}
log.Log().Info(fName, "message", "Sent shards to SPIKE Keeper instances.")
// Mark completion in Kubernetes
if err := lifecycle.MarkBootstrapComplete(); err != nil {
// Log but don't fail - bootstrap itself succeeded
log.Log().Warn(fName, "message",
"Could not mark bootstrap complete in ConfigMap", "err", err.Error())
}
fmt.Println("Bootstrap completed successfully!")
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// ShamirShares returns the total number of shares to be used in Shamir's
// Secret Sharing. It reads the value from the SPIKE_NEXUS_SHAMIR_SHARES
// environment variable.
//
// Returns:
// - The number of shares specified in the environment variable if it's a
// valid positive integer
// - The default value of 3 if the environment variable is unset, empty,
// or invalid
//
// This determines the total number of shares that will be created when
//
// splitting a secret.
func ShamirShares() int {
p := os.Getenv(env.NexusShamirShares)
if p != "" {
mv, err := strconv.Atoi(p)
if err == nil && mv > 0 {
return mv
}
}
return 3
}
// ShamirThreshold returns the minimum number of shares required to reconstruct
// the secret in Shamir's Secret Sharing scheme.
// It reads the value from the SPIKE_NEXUS_SHAMIR_THRESHOLD environment
// variable.
//
// Returns:
// - The threshold specified in the environment variable if it's a valid
// positive integer
// - The default value of 2 if the environment variable is unset, empty,
// or invalid
//
// This threshold value determines how many shares are needed to recover the
// original secret. It should be less than or equal to the total number of
// shares (ShamirShares()).
func ShamirThreshold() int {
p := os.Getenv(env.NexusShamirThreshold)
if p != "" {
mv, err := strconv.Atoi(p)
if err == nil && mv > 0 {
return mv
}
}
return 2
}
// validURL validates that a URL is properly formatted and uses HTTPS
func validURL(urlStr string) bool {
pu, err := url.Parse(urlStr)
if err != nil {
return false
}
return pu.Scheme == "https" && pu.Host != ""
}
// Keepers retrieves and parses the keeper peer configurations from the
// environment. It reads SPIKE_NEXUS_KEEPER_PEERS environment variable which
// should contain a comma-separated list of keeper URLs.
//
// The environment variable should be formatted as:
// 'https://localhost:8443,https://localhost:8543,https://localhost:8643'
//
// The SPIKE Keeper address mappings will be automatically assigned starting
// with the key "1" and incrementing by 1 for each subsequent SPIKE Keeper.
//
// Returns:
// - map[string]string: Mapping of keeper IDs to their URLs
//
// Panics if:
// - SPIKE_NEXUS_KEEPER_PEERS is not set
func Keepers() map[string]string {
p := os.Getenv(env.NexusKeeperPeers)
if p == "" {
panic("SPIKE_NEXUS_KEEPER_PEERS has to be configured in the environment")
}
urls := strings.Split(p, ",")
// Check for duplicate and empty URLs
urlMap := make(map[string]bool)
for i, u := range urls {
trimmedURL := strings.TrimSpace(u)
if trimmedURL == "" {
panic(fmt.Sprintf("Keepers: Empty URL found at position %d", i+1))
}
// Validate URL format and security
if !validURL(trimmedURL) {
panic(
fmt.Sprintf(
"Invalid or insecure URL at position %d: %s", i+1,
trimmedURL),
)
}
if urlMap[trimmedURL] {
panic("Duplicate keeper URL detected: " + trimmedURL)
}
urlMap[trimmedURL] = true
}
// The key of the map is the Shamir Shard index (starting from 1), and
// the value is the Keeper URL that corresponds to that shard index.
peers := make(map[string]string)
for i, u := range urls {
peers[strconv.Itoa(i+1)] = strings.TrimSpace(u)
}
return peers
}
// ConfigMapName returns the name of the ConfigMap used to store SPIKE
// Bootstrap state information.
//
// It retrieves the ConfigMap name from the SPIKE_BOOTSTRAP_CONFIGMAP_NAME
// environment variable. If the environment variable is not set, it returns
// the default value "spike-bootstrap-state".
//
// Returns:
// - A string containing the ConfigMap name for storing bootstrap state
func ConfigMapName() string {
cn := os.Getenv(env.BootstrapConfigMapName)
if cn == "" {
return "spike-bootstrap-state"
}
return cn
}
// StoreType represents the type of backend storage to use.
type StoreType string
const (
// Lite mode
// This mode converts SPIKE to an encryption-as-a-service app.
// It is used to store secrets in S3-compatible mediums (such as Minio)
// without actually persisting them to a backing store.
// In this mode SPIKE policies are "minimally" enforced, and the recommended
// way to manage RBAC is to use the object storage's policy rules instead.
Lite StoreType = "lite"
// Sqlite indicates a SQLite database storage backend
// This is the default backing store. SPIKE_NEXUS_BACKEND_STORE environment
// variable can override it.
Sqlite StoreType = "sqlite"
// Memory indicates an in-memory storage backend
// This mode is not recommended for production use as SPIKE will NOT rely on
// SPIKE Keeper instances for Disaster Recovery and Redundancy.
Memory StoreType = "memory"
)
// BackendStoreType determines which storage backend type to use based on the
// SPIKE_NEXUS_BACKEND_STORE environment variable. The value is
// case-insensitive.
//
// Valid values are:
// - "lite": Lite mode that does not use any backing store
// - "sqlite": Uses SQLite database storage
// - "memory": Uses in-memory storage
//
// If the environment variable is not set or contains an invalid value,
// it defaults to SQLite.
func BackendStoreType() StoreType {
st := os.Getenv(env.NexusBackendStore)
switch strings.ToLower(st) {
case string(Lite):
return Lite
case string(Sqlite):
return Sqlite
case string(Memory):
return Memory
default:
return Sqlite
}
}
// \\ 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"
"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"
appEnv "github.com/spiffe/spike/app/bootstrap/internal/env"
)
const k8sTrue = "true"
const k8sServiceAccountNamespace = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
// ShouldBootstrap determines whether the bootstrap process should be
// skipped based on the current environment and state. The function follows
// this decision logic:
//
// 1. 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 = "bootstrap.shouldSkipBootstrap"
// Memory backend doesn't need bootstrap.
if appEnv.BackendStoreType() == appEnv.Memory {
log.Log().Info(fName,
"message", "Skipping bootstrap for 'in memory' backend")
return false
}
// Lite backend doesn't need bootstrap.
if appEnv.BackendStoreType() == appEnv.Lite {
log.Log().Info(fName,
"message", "Skipping bootstrap for 'lite' backend")
return false
}
// Check if we're forcing bootstrap
if os.Getenv(env.BootstrapForce) == k8sTrue {
log.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, err := rest.InClusterConfig()
if err != nil {
// We're not in Kubernetes (bare-metal scenario)
// Bootstrap should proceed in non-k8s environments
if errors.Is(err, rest.ErrNotInCluster) {
log.Log().Info(fName,
"message", "Not running in Kubernetes, proceeding with bootstrap",
)
return true
}
// Some other error. Skip bootstrap.
log.Log().Error(fName,
"message",
"Could not determine cluster config. Skipping bootstrap",
"err", err.Error())
return false
}
// We're in Kubernetes - check the ConfigMap
clientset, err := kubernetes.NewForConfig(cfg)
if err != nil {
log.Log().Error(fName,
"message",
"Failed to create Kubernetes client. SKIPPING bootstrap.",
"err", err.Error())
// Can't check state, skip bootstrap.
return false
}
namespace := "spike"
// Read namespace from the service account if not specified
if nsBytes, err := os.ReadFile(k8sServiceAccountNamespace); err == nil {
namespace = string(nsBytes)
}
cm, err := clientset.CoreV1().ConfigMaps(namespace).Get(
context.Background(),
appEnv.ConfigMapName(),
k8sMeta.GetOptions{},
)
if err != nil {
// ConfigMap doesn't exist or can't read it - proceed with bootstrap
log.Log().Info(fName,
"message",
"ConfigMap not found or not readable, proceeding with bootstrap",
"err", err.Error())
return true
}
bootstrapCompleted := cm.Data["bootstrap-completed"] == k8sTrue
completedAt := cm.Data["completed-at"]
completedByPod := cm.Data["completed-by-pod"]
if bootstrapCompleted {
reason := fmt.Sprintf("completed at %s by pod %s",
completedAt, completedByPod)
log.Log().Info(fName,
"message", "Skipping bootstrap based on ConfigMap state",
"completed-at", completedAt,
"completed-by-pod", completedByPod,
"reason", reason,
)
return false
}
// Boostrap 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() error {
const fName = "bootstrap.markBootstrapComplete"
// Only mark complete in Kubernetes environments
config, err := rest.InClusterConfig()
if err != nil {
if errors.Is(err, rest.ErrNotInCluster) {
// Not in Kubernetes, nothing to mark
log.Log().Info(fName,
"message", "Not in Kubernetes, skipping completion marker")
return nil
}
return err
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return err
}
namespace := "spike"
if nsBytes, err := os.ReadFile(k8sServiceAccountNamespace); err == nil {
namespace = string(nsBytes)
}
// Create ConfigMap marking bootstrap as complete
cm := &k8s.ConfigMap{
ObjectMeta: k8sMeta.ObjectMeta{
Name: appEnv.ConfigMapName(),
},
Data: map[string]string{
"bootstrap-completed": k8sTrue,
"completed-at": time.Now().UTC().Format(time.RFC3339),
"completed-by-pod": os.Getenv("HOSTNAME"),
},
}
ctx := context.Background()
_, err = clientset.CoreV1().ConfigMaps(
namespace,
).Create(ctx, cm, k8sMeta.CreateOptions{})
if err != nil {
// Try to update if it already exists
_, err = clientset.CoreV1().ConfigMaps(
namespace,
).Update(ctx, cm, k8sMeta.UpdateOptions{})
}
if err != nil {
log.Log().Error(fName,
"message", "Failed to mark bootstrap complete", "err", err.Error())
return err
}
log.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/sha256"
"encoding/json"
"fmt"
"net/http"
"github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
"github.com/spiffe/spike-sdk-go/crypto"
"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/spiffe"
"github.com/spiffe/spike/internal/net"
)
// Source creates and returns a new SPIFFE X509Source for workload API
// communication. It establishes a connection to the SPIFFE workload API using
// the default endpoint socket. The function will terminate the program with
// exit code 1 if the source creation fails.
func Source() *workloadapi.X509Source {
const fName = "Source"
source, _, err := spiffe.Source(
context.Background(), spiffe.EndpointSocket(),
)
if err != nil {
log.FatalLn(fName, "message", "Failed to create source", "err", err)
}
return source
}
// MTLSClient creates an HTTP client configured for mutual TLS authentication
// using the provided X509Source. The client is configured with a predicate that
// validates peer IDs against the trusted keeper root. Only peers that pass the
// spiffeid.IsKeeper validation will be accepted for connections. The function
// will terminate the program with exit code 1 if client creation fails.
func MTLSClient(source *workloadapi.X509Source) *http.Client {
const fName = "MTLSClient"
client, err := network.CreateMTLSClientWithPredicate(
source, predicate.AllowKeeper,
)
if err != nil {
log.FatalLn(fName,
"message", "Failed to create mTLS client",
"err", err)
}
return client
}
// Payload marshals a secret sharing contribution into a JSON payload for
// transmission to a Keeper. It takes a secret sharing share and the target
// Keeper ID, validates the contribution is exactly 32 bytes, and returns the
// marshaled ShardContributionRequest as a byte slice. The function will
// terminate the program with exit code 1 if marshaling fails or if the
// contribution length is invalid.
func Payload(share secretsharing.Share, keeperID string) []byte {
const fName = "payload"
contribution, err := share.Value.MarshalBinary()
if err != nil {
log.FatalLn(fName, "message", "Failed to marshal share",
"err", err, "keeper_id", keeperID)
}
if len(contribution) != crypto.AES256KeySize {
log.FatalLn(fName,
"message", "invalid contribution length",
"len", len(contribution), "keeper_id", keeperID)
}
scr := reqres.ShardContributionRequest{}
shard := new([crypto.AES256KeySize]byte)
copy(shard[:], contribution)
scr.Shard = shard
md, err := json.Marshal(scr)
if err != nil {
log.FatalLn(fName,
"message", "Failed to marshal request",
"err", err, "keeper_id", keeperID)
}
return md
}
// Post sends an HTTP POST request to the specified URL using the provided
// client and payload data. The function is designed for sending shard
// contribution requests to keepers in a secure manner. It will terminate the
// program with exit code 1 if the POST request fails.
func Post(client *http.Client, u string, md []byte, keeperID string) error {
const fName = "post"
log.Log().Info(fName, "payload", fmt.Sprintf("%x", sha256.Sum256(md)))
_, err := net.Post(client, u, md)
if err != nil {
log.Log().Info(fName, "message",
"Failed to post",
"err", err, "keeper_id", keeperID)
}
return err
}
// \\ 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"
"strconv"
"github.com/cloudflare/circl/group"
shamir "github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/bootstrap/internal/env"
"github.com/spiffe/spike/app/bootstrap/internal/validation"
)
// 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 performs security validation and
// zeroing of sensitive data after use.
func RootShares() []shamir.Share {
const fName = "rootShares"
var rootKeySeed [crypto.AES256KeySize]byte
if _, err := rand.Read(rootKeySeed[:]); err != nil {
log.FatalLn(fName, "message", "key seed failure", "err", err.Error())
}
// Initialize parameters
g := group.P256
t := uint(env.ShamirThreshold() - 1) // Need t+1 shares to reconstruct
n := uint(env.ShamirShares()) // Total number of shares
log.Log().Info(fName, "t", t, "n", n)
// Create a secret from our 32-byte key:
rootSecret := g.NewScalar()
if err := rootSecret.UnmarshalBinary(rootKeySeed[:]); err != nil {
log.FatalLn(fName, "message", "Failed to unmarshal key: %v"+err.Error())
}
// 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)
log.Log().Info(fName, "message", "Generated Shamir shares")
rs := ss.Share(n)
// Security: Ensure the root key and shares are zeroed out after use.
validation.SanityCheck(rootSecret, rs)
log.Log().Info(fName, "message", "Successfully generated shards.")
return rs
}
// 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. The function will terminate the program with exit code 1 if the
// Keeper ID cannot be converted to an integer or if no matching share is found
// for the specified keeper.
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 {
log.FatalLn(
fName, "message", "Failed to convert keeper id to int", "err", err)
}
if sr.ID.IsEqual(group.P256.NewScalar().SetUint64(uint64(kid))) {
share = sr
break
}
}
if share.ID.IsZero() {
log.FatalLn(fName,
"message", "Failed to find share for keeper", "keeper_id", keeperID)
}
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"
)
// 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 url
import (
"net/url"
apiUrl "github.com/spiffe/spike-sdk-go/api/url"
"github.com/spiffe/spike-sdk-go/log"
)
// KeeperEndpoint constructs the full API endpoint URL for keeper contribution
// requests. It joins the provided keeper API root URL with the
// KeeperContribute path segment to create a complete endpoint URL for
// submitting secret shares to keepers. The function will terminate the program
// with exit code 1 if URL path joining fails.
func KeeperEndpoint(keeperAPIRoot string) string {
const fName = "keeperEndpoint"
u, err := url.JoinPath(
keeperAPIRoot, string(apiUrl.KeeperContribute),
)
if err != nil {
log.FatalLn(
fName, "message", "Failed to join path", "url", keeperAPIRoot,
)
}
return u
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package validation
import (
"github.com/cloudflare/circl/group"
shamir "github.com/cloudflare/circl/secretsharing"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/bootstrap/internal/env"
)
// SanityCheck 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.
//
// 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 doesn't 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 won't run after log.FatalLn
func SanityCheck(secret group.Scalar, shares []shamir.Share) {
const fName = "SanityCheck"
t := uint(env.ShamirThreshold() - 1) // Need t+1 shares to reconstruct
reconstructed, err := shamir.Recover(t, shares[:env.ShamirThreshold()])
// 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)
log.FatalLn(fName + ": Failed to recover: " + err.Error())
}
if !secret.IsEqual(reconstructed) {
// deferred will not run in a fatal crash.
reconstructed.SetUint64(0)
log.FatalLn(fName + ": Recovered secret does not match original")
}
}
// \\ 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 := spike.New() // Use the default Workload API Socket
fmt.Println("Connected to SPIKE Nexus.")
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#Close
defer api.Close() // Close the connection when done
path := "tenants/demo/db/creds"
// Create a Secret
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#PutSecret
err := api.PutSecret(path, map[string]string{
"username": "SPIKE",
"password": "SPIKE_Rocks",
})
if err != nil {
fmt.Println("Error writing secret:", err.Error())
return
}
// Read the Secret
// https://pkg.go.dev/github.com/spiffe/spike-sdk-go/api#GetSecret
secret, err := api.GetSecret(path)
if err != nil {
fmt.Println("Error reading secret:", err.Error())
return
}
if secret == nil {
fmt.Println("Secret not found.")
return
}
fmt.Println("Secret found:")
data := secret.Data
for k, v := range 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"
"crypto/fips140"
"fmt"
"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-sdk-go/spiffeid"
"github.com/spiffe/spike/app/keeper/internal/env"
"github.com/spiffe/spike/app/keeper/internal/net"
"github.com/spiffe/spike/internal/config"
)
const appName = "SPIKE Keeper"
func main() {
if env.BannerEnabled() {
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, config.KeeperVersion, log.Level(), fips140.Enabled(),
)
}
if mem.Lock() {
log.Log().Info(appName, "message", "Successfully locked memory.")
} else {
log.Log().Info(appName,
"message", "Memory is not locked. Please disable swap.")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
source, selfSPIFFEID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
log.FatalLn(err.Error())
}
defer spiffe.CloseSource(source)
// I should be a SPIKE Keeper.
if !spiffeid.IsKeeper(selfSPIFFEID) {
log.FatalLn(appName, "message",
"Authenticate: SPIFFE ID is not valid",
"spiffeid", selfSPIFFEID)
}
log.Log().Info(
appName, "message",
fmt.Sprintf("Started service: %s v%s", appName, 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 env
import (
"os"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// BannerEnabled returns whether to show the initial banner on app start based
// on the SPIKE_BANNER_ENABLED environment variable.
//
// The function reads the SPIKE_BANNER_ENABLED environment variable and returns:
// - true if the variable is not set (default behavior)
// - true if the variable is set to "true" (case-insensitive)
// - false for any other value
//
// The environment variable value is trimmed of whitespace and converted to
// lowercase before comparison.
func BannerEnabled() bool {
s := os.Getenv(env.BannerEnabled)
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return true
}
return s == "true"
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"github.com/spiffe/spike-sdk-go/config/env"
)
// TLSPort returns the TLS port for the Spike Keeper service.
// It first checks for a port specified in the SPIKE_KEEPER_TLS_PORT
// environment variable.
// If no environment variable is set, it defaults to ":8443".
//
// The returned string is in the format ":port" suitable for use with
// net/http Listen functions.
func TLSPort() string {
p := os.Getenv(env.KeeperTLSPort)
if p != "" {
return p
}
return ":8443"
}
// \\ 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/log"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/predicate"
"github.com/spiffe/spike/app/keeper/internal/env"
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
//
// The function does not return unless an error occurs, in which case it calls
// log.FatalF and terminates the program.
func Serve(appName string, source *workloadapi.X509Source) {
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.TLSPort(),
); err != nil {
log.FatalLn(appName, "message", "Failed to serve", "err", err.Error())
}
}
// \\ 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"
"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,
) error {
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
"github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"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, decodes it from Base64 encoding,
// and stores it in the system state.
//
// The function expects a Base64-encoded shard and a keeper ID in the request
// body. It performs the following operations:
// - Reads and validates the request body
// - Decodes the Base64-encoded shard
// - Stores the decoded 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:
// - error: nil if successful, otherwise one of:
// - errors.ErrReadFailure if request body cannot be read
// - errors.ErrParseFailure if request parsing fails or shard decoding fails
//
// Example request body:
//
// {
// "shard": "base64EncodedString",
// "keeperId": "uniqueIdentifier"
// }
//
// The function returns a 200 OK status with an empty response body on success,
// or a 400 Bad Request status with an error message if the shard content is
// invalid.
func RouteContribute(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeContribute"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.ShardContributionRequest, reqres.ShardContributionResponse](
requestBody, w,
reqres.ShardContributionResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
if request.Shard == nil {
responseBody := net.MarshalBody(reqres.ShardContributionResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return errors.ErrInvalidInput
}
// 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) {
responseBody := net.MarshalBody(reqres.ShardContributionResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return errors.ErrInvalidInput
}
// `state.SetShard` copies the shard. We can safely reset this one at [1].
state.SetShard(request.Shard)
responseBody := net.MarshalBody(reqres.ShardContributionResponse{}, w)
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
"github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"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, encodes it in Base64,
// and returns it to the requester.
//
// 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.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,
// or a 404 Not Found status if no shard exists in the system.
func RouteShard(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeShard"
journal.AuditRequest(fName, r, audit, journal.AuditRead)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.ShardRequest, reqres.ShardResponse](
requestBody, w,
reqres.ShardResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
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) {
log.Log().Error(fName, "message", "No shard found")
responseBody := net.MarshalBody(reqres.ShardResponse{
Err: data.ErrNotFound,
}, w)
net.Respond(http.StatusNotFound, responseBody, w)
return errors.ErrNotFound
}
responseBody := net.MarshalBody(reqres.ShardResponse{
Shard: sh,
}, w)
// Security: Reset response body before function exits.
defer func() {
mem.ClearBytes(responseBody)
}()
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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 (
"sync"
"github.com/spiffe/spike-sdk-go/crypto"
)
var shard [crypto.AES256KeySize]byte
var shardMutex sync.RWMutex
// 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"
"crypto/fips140"
"fmt"
cfg "github.com/spiffe/spike-sdk-go/config/env"
"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-sdk-go/spiffeid"
"github.com/spiffe/spike/app/nexus/internal/env"
"github.com/spiffe/spike/app/nexus/internal/initialization"
"github.com/spiffe/spike/app/nexus/internal/net"
"github.com/spiffe/spike/internal/config"
)
const appName = "SPIKE Nexus"
func main() {
if env.BannerEnabled() {
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, config.KeeperVersion, log.Level(), fips140.Enabled(),
)
}
if mem.Lock() {
log.Log().Info(appName, "message", "Successfully locked memory.")
} else {
log.Log().Info(appName,
"message", "Memory is not locked. Please disable swap.")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log.Log().Info(appName,
"message", "SPIFFE Trust Domain: "+cfg.TrustRootVal(),
)
source, selfSPIFFEID, err := spiffe.Source(ctx, spiffe.EndpointSocket())
if err != nil {
log.FatalLn(appName, "message", "failed to get source", "err", err.Error())
}
defer spiffe.CloseSource(source)
log.Log().Info(appName, "message", "self.spiffeid: "+selfSPIFFEID)
// I should be SPIKE Nexus.
if !spiffeid.IsNexus(selfSPIFFEID) {
log.FatalLn(appName,
"message",
"Authenticate: SPIFFE ID is not valid",
"spiffeid", selfSPIFFEID)
}
initialization.Initialize(source)
log.Log().Info(appName, "message", fmt.Sprintf(
"Started service: %s v%s",
appName, 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 env
import (
"os"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// StoreType represents the type of backend storage to use.
type StoreType string
const (
// Lite mode
// This mode converts SPIKE to an encryption-as-a-service app.
// It is used to store secrets in S3-compatible mediums (such as Minio)
// without actually persisting them to a backing store.
// In this mode SPIKE policies are "minimally" enforced, and the recommended
// way to manage RBAC is to use the object storage's policy rules instead.
Lite StoreType = "lite"
// Sqlite indicates a SQLite database storage backend
// This is the default backing store. SPIKE_NEXUS_BACKEND_STORE environment
// variable can override it.
Sqlite StoreType = "sqlite"
// Memory indicates an in-memory storage backend
// This mode is not recommended for production use as SPIKE will NOT rely on
// SPIKE Keeper instances for Disaster Recovery and Redundancy.
Memory StoreType = "memory"
)
// BackendStoreType determines which storage backend type to use based on the
// SPIKE_NEXUS_BACKEND_STORE environment variable. The value is
// case-insensitive.
//
// Valid values are:
// - "lite": Lite mode that does not use any backing store
// - "sqlite": Uses SQLite database storage
// - "memory": Uses in-memory storage
//
// If the environment variable is not set or contains an invalid value,
// it defaults to SQLite.
func BackendStoreType() StoreType {
st := os.Getenv(env.NexusBackendStore)
switch strings.ToLower(st) {
case string(Lite):
return Lite
case string(Sqlite):
return Sqlite
case string(Memory):
return Memory
default:
return Sqlite
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"strconv"
"time"
appEnv "github.com/spiffe/spike-sdk-go/config/env"
)
// DatabaseJournalMode returns the SQLite journal mode to use.
// It can be configured using the SPIKE_NEXUS_DB_JOURNAL_MODE environment
// variable.
//
// If the environment variable is not set, it defaults to "WAL"
// (Write-Ahead Logging).
func DatabaseJournalMode() string {
s := os.Getenv(appEnv.NexusDBJournalMode)
if s != "" {
return s
}
return "WAL"
}
// DatabaseBusyTimeoutMs returns the SQLite busy timeout in milliseconds.
// It can be configured using the SPIKE_NEXUS_DB_BUSY_TIMEOUT_MS environment
// variable. The value must be a positive integer.
//
// If the environment variable is not set or contains an invalid value,
// it defaults to 5000 milliseconds (5 seconds).
func DatabaseBusyTimeoutMs() int {
p := os.Getenv(appEnv.NexusDBBusyTimeoutMS)
if p != "" {
bt, err := strconv.Atoi(p)
if err == nil && bt > 0 {
return bt
}
}
return 5000
}
// DatabaseMaxOpenConns returns the maximum number of open database connections.
// It can be configured using the SPIKE_NEXUS_DB_MAX_OPEN_CONNS environment
// variable. The value must be a positive integer.
//
// If the environment variable is not set or contains an invalid value,
// it defaults to 10 connections.
func DatabaseMaxOpenConns() int {
p := os.Getenv(appEnv.NexusDBMaxOpenConns)
if p != "" {
moc, err := strconv.Atoi(p)
if err == nil && moc > 0 {
return moc
}
}
return 10
}
// DatabaseMaxIdleConns returns the maximum number of idle database connections.
// It can be configured using the SPIKE_NEXUS_DB_MAX_IDLE_CONNS environment
// variable. The value must be a positive integer.
//
// If the environment variable is not set or contains an invalid value,
// it defaults to 5 connections.
func DatabaseMaxIdleConns() int {
p := os.Getenv(appEnv.NexusDBMaxIdleConns)
if p != "" {
mic, err := strconv.Atoi(p)
if err == nil && mic > 0 {
return mic
}
}
return 5
}
// DatabaseConnMaxLifetimeSec returns the maximum lifetime duration for a
// database connection. It can be configured using the
// SPIKE_NEXUS_DB_CONN_MAX_LIFETIME environment variable.
// The value should be a valid Go duration string (e.g., "1h", "30m").
//
// If the environment variable is not set or contains an invalid duration,
// it defaults to 1 hour.
func DatabaseConnMaxLifetimeSec() time.Duration {
p := os.Getenv(appEnv.NexusDBConnMaxLifetime)
if p != "" {
d, err := time.ParseDuration(p)
if err == nil {
return d
}
}
return time.Hour
}
// DatabaseOperationTimeout returns the duration to use for database operations.
// It can be configured using the SPIKE_NEXUS_DB_OPERATION_TIMEOUT environment
// variable. The value should be a valid Go duration string (e.g., "10s", "1m").
//
// If the environment variable is not set or contains an invalid duration,
// it defaults to 15 seconds.
func DatabaseOperationTimeout() time.Duration {
p := os.Getenv(appEnv.NexusDBOperationTimeout)
if p != "" {
d, err := time.ParseDuration(p)
if err == nil {
return d
}
}
return 15 * time.Second
}
// DatabaseInitializationTimeout returns the duration to wait for database
// initialization.
//
// The timeout is read from the environment variable
// `SPIKE_NEXUS_DB_INITIALIZATION_TIMEOUT`. If this variable is set and its
// value can be parsed as a duration (e.g., "1m30s"), it is used.
// Otherwise, the function defaults to a timeout of 30 seconds.
func DatabaseInitializationTimeout() time.Duration {
p := os.Getenv(appEnv.NexusDBInitializationTimeout)
if p != "" {
d, err := time.ParseDuration(p)
if err == nil {
return d
}
}
return 30 * time.Second
}
// DatabaseSkipSchemaCreation determines if schema creation should be skipped.
// It checks the "SPIKE_NEXUS_DB_SKIP_SCHEMA_CREATION" env variable to decide.
// If the env variable is set and its value is "true", it returns true.
// Otherwise, it returns false.
func DatabaseSkipSchemaCreation() bool {
p := os.Getenv(appEnv.NexusDBSkipSchemaCreation)
if p != "" {
s, err := strconv.ParseBool(p)
if err == nil {
return s
}
}
return false
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// validURL validates that a URL is properly formatted and uses HTTPS
func validURL(urlStr string) bool {
pu, err := url.Parse(urlStr)
if err != nil {
return false
}
return pu.Scheme == "https" && pu.Host != ""
}
// Keepers retrieves and parses the keeper peer configurations from the
// environment. It reads SPIKE_NEXUS_KEEPER_PEERS environment variable which
// should contain a comma-separated list of keeper URLs.
//
// The environment variable should be formatted as:
// 'https://localhost:8443,https://localhost:8543,https://localhost:8643'
//
// The SPIKE Keeper address mappings will be automatically assigned starting
// with the key "1" and incrementing by 1 for each subsequent SPIKE Keeper.
//
// Returns:
// - map[string]string: Mapping of keeper IDs to their URLs
//
// Panics if:
// - SPIKE_NEXUS_KEEPER_PEERS is not set
func Keepers() map[string]string {
p := os.Getenv(env.NexusKeeperPeers)
if p == "" {
panic("SPIKE_NEXUS_KEEPER_PEERS has to be configured in the environment")
}
urls := strings.Split(p, ",")
// Check for duplicate and empty URLs
urlMap := make(map[string]bool)
for i, u := range urls {
trimmedURL := strings.TrimSpace(u)
if trimmedURL == "" {
panic(fmt.Sprintf("Keepers: Empty URL found at position %d", i+1))
}
// Validate URL format and security
if !validURL(trimmedURL) {
panic(
fmt.Sprintf(
"Invalid or insecure URL at position %d: %s", i+1,
trimmedURL),
)
}
if urlMap[trimmedURL] {
panic("Duplicate keeper URL detected: " + trimmedURL)
}
urlMap[trimmedURL] = true
}
// The key of the map is the Shamir Shard index (starting from 1), and
// the value is the Keeper URL that corresponds to that shard index.
peers := make(map[string]string)
for i, u := range urls {
peers[strconv.Itoa(i+1)] = strings.TrimSpace(u)
}
return peers
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"github.com/spiffe/spike-sdk-go/config/env"
)
// TLSPort returns the TLS port for the Spike Nexus service.
// It reads from the SPIKE_NEXUS_TLS_PORT environment variable.
// If the environment variable is not set, it returns the default port ":8553".
func TLSPort() string {
p := os.Getenv(env.NexusTLSPort)
if p != "" {
return p
}
return ":8553"
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// BannerEnabled returns whether to show the initial banner on app start based
// on the SPIKE_BANNER_ENABLED environment variable.
//
// The function reads the SPIKE_BANNER_ENABLED environment variable and returns:
// - true if the variable is not set (default behavior)
// - true if the variable is set to "true" (case-insensitive)
// - false for any other value
//
// The environment variable value is trimmed of whitespace and converted to
// lowercase before comparison.
func BannerEnabled() bool {
s := os.Getenv(env.BannerEnabled)
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return true
}
return s == "true"
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"time"
"github.com/spiffe/spike-sdk-go/config/env"
)
// RecoveryOperationMaxInterval returns the maximum interval duration for
// recovery backoff retry algorithm. The interval is determined by the
// environment variable `SPIKE_NEXUS_RECOVERY_MAX_INTERVAL`.
//
// If the environment variable is not set or is not a valid duration
// string, then it defaults to 60 seconds.
func RecoveryOperationMaxInterval() time.Duration {
e := os.Getenv(env.NexusRecoveryMaxInterval)
if e != "" {
if d, err := time.ParseDuration(e); err == nil {
return d
}
}
return 60 * time.Second
}
// RecoveryKeeperUpdateInterval returns the duration between keeper updates for
// SPIKE Nexus. It first attempts to read the duration from the
// SPIKE_NEXUS_KEEPER_UPDATE_INTERVAL environment variable. If the environment
// variable is set and contains a valid duration string (as parsed by
// time.ParseDuration), that duration is returned. Otherwise, it returns a
// default value of 5 minutes.
func RecoveryKeeperUpdateInterval() time.Duration {
e := os.Getenv(env.NexusKeeperUpdateInterval)
if e != "" {
if d, err := time.ParseDuration(e); err == nil {
return d
}
}
return 5 * time.Minute
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"strconv"
"github.com/spiffe/spike-sdk-go/config/env"
)
// MaxSecretVersions returns the maximum number of versions to retain for each
// secret. It reads from the SPIKE_NEXUS_MAX_SECRET_VERSIONS environment
// variable which should contain a positive integer value.
// If the environment variable is not set, contains an invalid integer, or
// specifies a non-positive value, it returns the default of 10 versions.
func MaxSecretVersions() int {
p := os.Getenv(env.NexusMaxEntryVersions)
if p != "" {
mv, err := strconv.Atoi(p)
if err == nil && mv > 0 {
return mv
}
}
return 10
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package env
import (
"os"
"strconv"
"github.com/spiffe/spike-sdk-go/config/env"
)
// ShamirShares returns the total number of shares to be used in Shamir's
// Secret Sharing. It reads the value from the SPIKE_NEXUS_SHAMIR_SHARES
// environment variable.
//
// Returns:
// - The number of shares specified in the environment variable if it's a
// valid positive integer
// - The default value of 3 if the environment variable is unset, empty,
// or invalid
//
// This determines the total number of shares that will be created when
//
// splitting a secret.
func ShamirShares() int {
p := os.Getenv(env.NexusShamirShares)
if p != "" {
mv, err := strconv.Atoi(p)
if err == nil && mv > 0 {
return mv
}
}
return 3
}
// ShamirThreshold returns the minimum number of shares required to reconstruct
// the secret in Shamir's Secret Sharing scheme.
// It reads the value from the SPIKE_NEXUS_SHAMIR_THRESHOLD environment
// variable.
//
// Returns:
// - The threshold specified in the environment variable if it's a valid
// positive integer
// - The default value of 2 if the environment variable is unset, empty,
// or invalid
//
// This threshold value determines how many shares are needed to recover the
// original secret. It should be less than or equal to the total number of
// shares (ShamirShares()).
func ShamirThreshold() int {
p := os.Getenv(env.NexusShamirThreshold)
if p != "" {
mv, err := strconv.Atoi(p)
if err == nil && mv > 0 {
return mv
}
}
return 2
}
// \\ 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/log"
"github.com/spiffe/spike/app/nexus/internal/env"
"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
//
// The source parameter provides the X.509 certificates and private keys
// needed for SPIFFE-based authentication when communicating with SPIKE Keepers.
// This parameter is only used for SQLite and Lite backend types.
//
// Backend type configuration is determined by env.BackendStoreType().
// Valid backend types are: 'sqlite', 'lite', or 'memory'.
//
// Note: This function will call log.Fatal and terminate the program if an
// invalid backend store type is configured.
func Initialize(source *workloadapi.X509Source) {
const fName = "Initialize"
requireBackingStoreToBootstrap := env.BackendStoreType() == env.Sqlite ||
env.BackendStoreType() == 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.BackendStoreType() == env.Memory
if devMode {
log.Log().Warn(fName, "message", "In-memory store will be used.")
log.Log().Warn(fName, "message", "Will not use SPIKE Keepers.")
log.Log().Warn(fName,
"message",
"This mode is NOT recommended for production use.")
// `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: '"+env.BackendStoreType()+"'."+
" Please set SPIKE_BACKEND_STORE_TYPE to 'sqlite', 'lite', or '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/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
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"
if env.BackendStoreType() == env.Memory {
log.Log().Warn(fName, "message", "In memory mode; skipping recovery")
// Assume successful initialization, since initialization is not needed.
return true
}
for keeperID, keeperAPIRoot := range env.Keepers() {
log.Log().Info(fName, "id", keeperID, "url", keeperAPIRoot)
u := shardURL(keeperAPIRoot)
if u == "" {
continue
}
data := shardResponse(source, u)
if len(data) == 0 {
continue
}
res := unmarshalShardResponse(data)
// Security: Reset data before the function exits.
mem.ClearBytes(data)
if res == nil {
continue
}
if mem.Zeroed32(res.Shard) {
log.Log().Info(fName, "message", "Shard is zeroed")
continue
}
successfulKeeperShards[keeperID] = res.Shard
if len(successfulKeeperShards) != env.ShamirThreshold() {
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 {
// This is a configuration error; we cannot recover from it,
// and it may cause further security issues. Crash immediately.
log.FatalLn(
fName, "message", "Failed to convert keeper ID to int", "err", err,
)
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) {
log.FatalLn(fName, "message", "Failed to recover root key")
}
// 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"
"errors"
"math/big"
"time"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/spiffe/spike-sdk-go/crypto"
"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"
"github.com/spiffe/spike/app/nexus/internal/env"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
var (
ErrRecoveryRetry = errors.New("recovery failed; retrying")
)
// 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 *workloadapi.X509Source: An X509Source used for authenticating
// with SPIKE Keeper nodes
func InitializeBackingStoreFromKeepers(source *workloadapi.X509Source) {
const fName = "InitializeBackingStoreFromKeepers"
log.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() {
log.Log().Info(fName, "message", "Resetting successfulKeeperShards")
for id := range successfulKeeperShards {
// Note: you 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.Do(ctx, func() (bool, error) {
log.Log().Info(fName, "message", "retry:"+time.Now().String())
initSuccessful := iterateKeepersAndInitializeState(
source, successfulKeeperShards,
)
if initSuccessful {
log.Log().Info(fName, "message", "Initialization successful.")
return true, nil
}
log.Log().Warn(fName,
"message", "Initialization unsuccessful. Will retry.",
"keepersSoFar", len(successfulKeeperShards),
)
return false, ErrRecoveryRetry
},
retry.WithBackOffOptions(
retry.WithMaxInterval(env.RecoveryOperationMaxInterval()),
retry.WithMaxElapsedTime(0), // Retry forever.
),
)
// This should never happen since the above loop retries forever:
if err != nil {
log.FatalLn(fName, "message", "Initialization failed", "err", err)
}
}
// 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.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 {
log.FatalLn(
fName,
"message",
"Bad input: ID or Value of a shard is zero. Exiting recovery",
)
return
}
}
log.Log().Info(fName,
"message", "Recovering backing store using pilot shards",
"threshold", env.ShamirThreshold(),
"len", len(shards),
)
// Ensure we have at least the threshold number of shards
if len(shards) < env.ShamirThreshold() {
log.Log().Error(fName, "message", "Insufficient shards for recovery",
"provided", len(shards), "required", env.ShamirThreshold())
return
}
log.Log().Info(fName,
"message", "Recovering backing store using pilot shards")
// Recover the root key using the threshold number of shards
rk := ComputeRootKeyFromShards(shards)
if rk == nil || mem.Zeroed32(rk) {
log.FatalLn(fName, "message", "Failed to recover root key")
}
// Security: Ensure the root key is zeroed out after use.
defer func() {
mem.ClearRawBytes(rk)
}()
log.Log().Info(fName, "message", "Initializing state and root key")
state.Initialize(rk)
source, _, err := spiffe.Source(
context.Background(), spiffe.EndpointSocket(),
)
if err != nil {
log.Log().Info(fName, "message", "Failed to create source", "err", err)
return
}
defer spiffe.CloseSource(source)
// Don't wait for the next cycle in `SendShardsPeriodically`.
// Send the shards asap.
sendShardsToKeepers(source, env.Keepers())
}
// 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 *workloadapi.X509Source: An X509Source used for creating mTLS
// connections to keepers
func SendShardsPeriodically(source *workloadapi.X509Source) {
const fName = "SendShardsPeriodically"
log.Log().Info(fName, "message", "Will send shards to keepers")
ticker := time.NewTicker(env.RecoveryKeeperUpdateInterval())
defer ticker.Stop()
for range ticker.C {
log.Log().Info(fName, "message", "Sending shards to keepers")
// if no root key, then skip.
if state.RootKeyZero() {
log.Log().Warn(fName, "message", "No root key; skipping")
continue
}
keepers := env.Keepers()
if len(keepers) < env.ShamirShares() {
log.FatalLn(fName + ": not enough keepers")
}
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 an empty slice. 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 slice 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.
//
// Returns:
// - []*[32]byte: A slice of byte array pointers representing the recovery
// shards. Returns an empty slice if the root key is not available or if
// shard generation fails.
//
// Example:
//
// shards := NewPilotRecoveryShards()
// for _, shard := range shards {
// // Store each shard securely
// storeShard(shard)
// }
func NewPilotRecoveryShards() map[int]*[crypto.AES256KeySize]byte {
const fName = "NewPilotRecoveryShards"
log.Log().Info(fName, "message", "Generating pilot recovery shards")
if state.RootKeyZero() {
log.Log().Warn(fName, "message", "No root key; skipping")
return nil
}
rootSecret, rootShards := computeShares()
// sanityCheck crashes the app if shards are corrupted.
sanityCheck(rootSecret, rootShards)
// 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.Log().Info(fName, "message", "Generating shard", "shard.id", shard.ID)
contribution, err := shard.Value.MarshalBinary()
if err != nil {
log.Log().Error(fName, "message", "Failed to marshal shard")
return nil
}
if len(contribution) != crypto.AES256KeySize {
log.Log().Error(fName, "message", "Length of shard is unexpected")
return nil
}
bb, err := shard.ID.MarshalBinary()
if err != nil {
log.Log().Error(fName, "message", "Failed to unmarshal shard ID")
return nil
}
bigInt := new(big.Int).SetBytes(bb)
ii := bigInt.Uint64()
if len(contribution) != crypto.AES256KeySize {
log.Log().Error(fName, "message", "Length of shard is unexpected")
return nil
}
var rs [crypto.AES256KeySize]byte
copy(rs[:], contribution)
log.Log().Info(fName, "message", "Generated shares", "len", len(rs))
result[int(ii)] = &rs
}
log.Log().Info(fName,
"message", "Successfully generated pilot recovery shards.")
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/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
)
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
err := share.Value.UnmarshalBinary(shamirShard.Value[:])
if err != nil {
log.FatalLn(fName + ": Failed to unmarshal share: " + err.Error())
}
shares = append(shares, share)
}
// Recover the secret
// The first parameter to Recover is threshold-1
// We need the threshold from the environment
threshold := env.ShamirThreshold()
reconstructed, err := secretsharing.Recover(uint(threshold-1), shares)
if err != nil {
// Security: Reset shares.
// Defer won't get called because `log.FatalLn` terminates the program.
for _, s := range shares {
s.ID.SetUint64(0)
s.Value.SetUint64(0)
}
log.FatalLn(fName + ": Failed to recover: " + err.Error())
}
if reconstructed == nil {
// Security: Reset shares.
// Defer won't get called because `log.FatalLn` terminates the program.
for _, s := range shares {
s.ID.SetUint64(0)
s.Value.SetUint64(0)
}
log.FatalLn(fName + ": Failed to reconstruct the root key")
}
if reconstructed != nil {
binaryRec, err := reconstructed.MarshalBinary()
if err != nil {
// Security: Zero out:
reconstructed.SetUint64(0)
log.FatalLn(fName + ": Failed to marshal: " + err.Error())
return &[crypto.AES256KeySize]byte{}
}
if len(binaryRec) != crypto.AES256KeySize {
log.FatalLn(fName + ": Reconstructed root key has incorrect length")
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/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
)
// sanityCheck 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.
//
// 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 doesn't 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 won't run after log.FatalLn
func sanityCheck(secret group.Scalar, shares []shamir.Share) {
const fName = "sanityCheck"
t := uint(env.ShamirThreshold() - 1) // Need t+1 shares to reconstruct
reconstructed, err := shamir.Recover(t, shares[:env.ShamirThreshold()])
// 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)
log.FatalLn(fName + ": Failed to recover: " + err.Error())
}
if !secret.IsEqual(reconstructed) {
// deferred will not run in a fatal crash.
reconstructed.SetUint64(0)
log.FatalLn(fName + ": Recovered secret does not match original")
}
}
// 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.
func computeShares() (group.Scalar, []shamir.Share) {
const fName = "computeShares"
log.Log().Info(fName, "message", "Computing Shamir shares")
state.LockRootKey()
defer state.UnlockRootKey()
rk := state.RootKeyNoLock()
if rk == nil || mem.Zeroed32(rk) {
log.FatalLn(fName, "message", "root key is nil or zeroed")
}
// Initialize parameters
g := group.P256
t := uint(env.ShamirThreshold() - 1) // Need t+1 shares to reconstruct
n := uint(env.ShamirShares()) // Total number of shares
log.Log().Info(fName, "t", t, "n", n)
// Create a secret from our 32-byte key:
secret := g.NewScalar()
if err := secret.UnmarshalBinary(rk[:]); err != nil {
log.FatalLn(fName + ": Failed to unmarshal key: %v" + err.Error())
}
// 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(rk[:])
ss := shamir.New(reader, t, secret)
log.Log().Info(fName, "message", "Generated Shamir shares")
computedShares := ss.Share(n)
// secret is a pointer type; ss.Share(n) is a slice
// shares will have monotonically increasing IDs, starting from 1.
return secret, computedShares
}
// \\ 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"
"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/log"
network "github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike-sdk-go/predicate"
"github.com/spiffe/spike/internal/net"
)
func shardURL(keeperAPIRoot string) string {
const fName = "shardURL"
u, err := url.JoinPath(keeperAPIRoot, string(apiUrl.KeeperShard))
if err != nil {
log.Log().Warn(
fName, "message", "Failed to join path", "url", keeperAPIRoot,
)
return ""
}
return u
}
func shardResponse(source *workloadapi.X509Source, u string) []byte {
const fName = "shardResponse"
if source == nil {
log.Log().Warn(fName, "message", "Source is nil")
return []byte{}
}
shardRequest := reqres.ShardRequest{}
md, err := json.Marshal(shardRequest)
if err != nil {
log.Log().Warn(fName,
"message", "Failed to marshal request",
"err", err)
return []byte{}
}
client, err := network.CreateMTLSClientWithPredicate(
source,
// Security: Only get shards from SPIKE Keepers.
predicate.AllowKeeper,
)
if err != nil {
log.Log().Warn(fName,
"message", "Failed to create mTLS client",
"err", err)
return []byte{}
}
data, err := net.Post(client, u, md)
if err != nil {
log.Log().Warn(fName,
"message", "Failed to post",
"err", err)
}
if len(data) == 0 {
log.Log().Info(fName, "message", "No data")
return []byte{}
}
return data
}
func unmarshalShardResponse(data []byte) *reqres.ShardResponse {
const fName = "unmarshalShardResponse"
var res reqres.ShardResponse
err := json.Unmarshal(data, &res)
if err != nil {
log.Log().Info(fName, "message",
"Failed to unmarshal response", "err", err)
return nil
}
return &res
}
// \\ 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"
"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"
"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.
// Note that we recompute shares for each keeper rather than computing them once
// and distributing them. 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. findShare() ensures each keeper receives its designated share
// This approach simplifies the code flow and maintains consistency across
// potential system restarts or failures.
//
// Note that sendSharesToKeepers optimistically moves on to the next SPIKE
// Keeper in the list on error. This is okay, because SPIKE Nexus may not
// need all keepers to be healthy all at once, and since we periodically
// send shards to keepers, provided there is no configuration mistake,
// all SPIKE Keepers will get their shards eventually.
func sendShardsToKeepers(
source *workloadapi.X509Source, keepers map[string]string,
) {
const fName = "sendShardsToKeepers"
for keeperID, keeperAPIRoot := range keepers {
u, err := url.JoinPath(
keeperAPIRoot, string(apiUrl.KeeperContribute),
)
if err != nil {
log.Log().Warn(
fName, "message", "Failed to join path", "url", keeperAPIRoot,
)
continue
}
// Security: Only SPIKE Keeper can send shards to SPIKE Nexus
client, err := network.CreateMTLSClientWithPredicate(
source, predicate.AllowKeeper,
)
if err != nil {
log.Log().Warn(fName,
"message", "Failed to create mTLS client",
"err", err)
continue
}
if state.RootKeyZero() {
log.Log().Warn(fName, "message", "rootKey is zero; moving on...")
continue
}
rootSecret, rootShares := computeShares()
sanityCheck(rootSecret, rootShares)
var share secretsharing.Share
for _, sr := range rootShares {
kid, err := strconv.Atoi(keeperID)
if err != nil {
log.Log().Warn(
fName, "message", "Failed to convert keeper id to int", "err", err)
continue
}
if sr.ID.IsEqual(group.P256.NewScalar().SetUint64(uint64(kid))) {
share = sr
break
}
}
if share.ID.IsZero() {
log.Log().Warn(fName,
"message", "Failed to find share for keeper", "keeper_id", keeperID)
continue
}
rootSecret.SetUint64(0)
contribution, err := share.Value.MarshalBinary()
if err != nil {
log.Log().Warn(fName, "message", "Failed to marshal share",
"err", err, "keeper_id", keeperID)
// Security: Ensure that the contribution is zeroed out before
// the next iteration.
mem.ClearBytes(contribution)
// Security: Ensure that the share is zeroed out before
// the next iteration.
share.Value.SetUint64(0)
// Security: Ensure that the rootShares are zeroed out before
// the function returns.
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
log.Log().Warn(fName,
"message", "Failed to marshal share",
"err", err, "keeper_id", keeperID)
continue
}
if len(contribution) != crypto.AES256KeySize {
// Security: Ensure that the contribution is zeroed out before
// the next iteration.
//
// Note that you cannot do `mem.ClearRawBytes(contribution)` because
// the contribution is a slice, not a struct; we use `mem.ClearBytes()`
// instead.
mem.ClearBytes(contribution)
// Security: Ensure that the share is zeroed out before
// the next iteration.
share.Value.SetUint64(0)
// Security: Ensure that the rootShares are zeroed out before
// the function returns.
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
log.Log().Warn(fName,
"message", "invalid contribution length",
"len", len(contribution), "keeper_id", keeperID)
continue
}
scr := reqres.ShardContributionRequest{}
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, err := json.Marshal(scr)
// Security: Erase scr.Shard when no longer in use.
mem.ClearRawBytes(scr.Shard)
// Security: Ensure that the contribution is zeroed out before
// the next iteration.
mem.ClearBytes(contribution)
// Security: Ensure that the share is zeroed out before
// the next iteration.
share.Value.SetUint64(0)
// Security: Ensure that the rootShares are zeroed out before
// the function returns.
for i := range rootShares {
rootShares[i].Value.SetUint64(0)
}
if err != nil {
log.Log().Warn(fName,
"message", "Failed to marshal request",
"err", err, "keeper_id", keeperID)
continue
}
_, err = net.Post(client, u, md)
// Security: Ensure that the md is zeroed out before
// the next iteration.
mem.ClearBytes(md)
if err != nil {
log.Log().Warn(fName, "message",
"Failed to post",
"err", err, "keeper_id", keeperID)
continue
}
}
}
// \\ 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/log"
"github.com/spiffe/spike-sdk-go/net"
"github.com/spiffe/spike/app/nexus/internal/env"
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
//
// The function does not return unless an error occurs, in which case it calls
// log.FatalF and terminates the program.
func Serve(appName string, source *workloadapi.X509Source) {
if err := net.Serve(
source,
func() { routing.HandleRoute(http.Route) },
env.TLSPort(),
); err != nil {
log.FatalLn(appName, "message", "Failed to serve", "err", err.Error())
}
}
// \\ 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"
"time"
"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/api/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"
)
// RoutePutPolicy handles HTTP PUT requests for creating new policies.
// It processes the request body to create a policy with the specified name,
// SPIFFE ID pattern, path pattern, and permissions.
//
// The function expects a JSON request body containing:
// - Name: policy name
// - SpiffeIdPattern: SPIFFE ID matching pattern
// - PathPattern: path matching pattern
// - Permissions: set of allowed permissions
//
// On success, it returns a JSON response with the created 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 creation data
// - audit: Audit entry for logging the policy creation action
//
// Returns:
// - error: nil on successful policy creation, 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,
) error {
const fName = "routePutPolicy"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrParseFailure
}
request := net.HandleRequest[
reqres.PolicyCreateRequest, reqres.PolicyCreateResponse](
requestBody, w,
reqres.PolicyCreateResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrReadFailure
}
err := guardPutPolicyRequest(*request, w, r)
if err != nil {
return err
}
name := request.Name
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
permissions := request.Permissions
policy, err := state.CreatePolicy(data.Policy{
ID: "",
Name: name,
SPIFFEIDPattern: SPIFFEIDPattern,
PathPattern: pathPattern,
Permissions: permissions,
CreatedAt: time.Time{},
CreatedBy: "",
})
if err != nil {
log.Log().Warn(fName, "message", "Failed to create policy", "err", err)
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrInternal,
}, w)
net.Respond(http.StatusInternalServerError, responseBody, w)
log.Log().Error(fName, "message", data.ErrInternal)
return err
}
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
ID: policy.ID,
}, w)
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardPutPolicyRequest(
request reqres.PolicyCreateRequest, w http.ResponseWriter, r *http.Request,
) error {
name := request.Name
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
permissions := request.Permissions
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
// Request "write" access to the ACL system for the SPIFFE ID.
allowed := state.CheckAccess(
sid.String(), auth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateName(name)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
err = validation.ValidateSPIFFEIDPattern(SPIFFEIDPattern)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
err = validation.ValidatePathPattern(pathPattern)
if err != nil {
responseBody :=
net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
err = validation.ValidatePermissions(permissions)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyCreateResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
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"
"github.com/spiffe/spike-sdk-go/api/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"
)
// 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:
// - error: nil on successful policy deletion, error otherwise
//
// Example request body:
//
// {
// "id": "policy-123"
// }
//
// Example success response:
//
// {}
//
// Example error response:
//
// {
// "err": "Internal server error"
// }
//
// Possible errors:
// - Failed to read request body
// - Failed to parse request body
// - Failed to marshal response body
// - Failed to delete policy (internal server error)
func RouteDeletePolicy(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeDeletePolicy"
journal.AuditRequest(fName, r, audit, journal.AuditDelete)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.PolicyDeleteRequest, reqres.PolicyDeleteResponse](
requestBody, w,
reqres.PolicyDeleteResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
policyID := request.ID
err := guardDeletePolicyRequest(*request, w, r)
if err != nil {
return err
}
err = state.DeletePolicy(policyID)
if err != nil {
log.Log().Warn(fName, "message", "Failed to delete policy", "err", err)
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrInternal,
}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
log.Log().Error(fName, "message", data.ErrInternal)
return err
}
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardDeletePolicyRequest(
request reqres.PolicyDeleteRequest, w http.ResponseWriter, r *http.Request,
) error {
policyID := request.ID
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePolicyID(policyID)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(), auth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
responseBody := net.MarshalBody(reqres.PolicyDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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"
"github.com/spiffe/spike-sdk-go/api/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"
)
// 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:
// - error: 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
// - Failed to marshal response body
func RouteListPolicies(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
fName := "routeListPolicies"
journal.AuditRequest(fName, r, audit, journal.AuditList)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.PolicyListRequest, reqres.PolicyListResponse](
requestBody, w,
reqres.PolicyListResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
err := guardListPolicyRequest(*request, w, r)
if err != nil {
return err
}
var policies []data.Policy
SPIFFEIDPattern := request.SPIFFEIDPattern
pathPattern := request.PathPattern
switch {
case SPIFFEIDPattern != "":
policies, err = state.ListPoliciesBySPIFFEIDPattern(SPIFFEIDPattern)
if err != nil {
return err
}
case pathPattern != "":
policies, err = state.ListPoliciesByPathPattern(pathPattern)
if err != nil {
return err
}
default:
policies, err = state.ListPolicies()
if err != nil {
return err
}
}
responseBody := net.MarshalBody(reqres.PolicyListResponse{
Policies: policies,
}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardListPolicyRequest(
_ reqres.PolicyListRequest, w http.ResponseWriter, r *http.Request,
) error {
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.PolicyListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
allowed := state.CheckAccess(
sid.String(), auth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionList},
)
if !allowed {
responseBody := net.MarshalBody(reqres.PolicyListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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 (
"errors"
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/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"
)
// 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:
// - error: nil on successful retrieval or policy not found, error 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,
) error {
const fName = "routeGetPolicy"
journal.AuditRequest(fName, r, audit, journal.AuditRead)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return apiErr.ErrReadFailure
}
request := net.HandleRequest[
reqres.PolicyReadRequest, reqres.PolicyReadResponse](
requestBody, w,
reqres.PolicyReadResponse{Err: data.ErrBadInput},
)
if request == nil {
return apiErr.ErrParseFailure
}
err := guardReadPolicyRequest(*request, w, r)
if err != nil {
return err
}
policyID := request.ID
policy, err := state.GetPolicy(policyID)
if err == nil {
log.Log().Info(fName, "message", "Policy found")
} else if errors.Is(err, state.ErrPolicyNotFound) {
log.Log().Info(fName, "message", "Policy not found")
res := reqres.PolicyReadResponse{Err: data.ErrNotFound}
responseBody := net.MarshalBody(res, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusNotFound, responseBody, w)
log.Log().Info(fName, "message", "Policy not found: returning nil")
return nil
} else {
// I should not be here, normally.
log.Log().Info(fName, "message", "Failed to retrieve policy", "err", err)
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrInternal}, w,
)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
log.Log().Warn(fName, "message", "problem retrieving policy",
"err", data.ErrInternal)
return err
}
responseBody := net.MarshalBody(
reqres.PolicyReadResponse{Policy: policy}, w,
)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardReadPolicyRequest(
request reqres.PolicyReadRequest, w http.ResponseWriter, r *http.Request,
) error {
policyID := request.ID
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePolicyID(policyID)
if err != nil {
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
return apiErr.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(), auth.PathSystemPolicyAccess,
[]data.PolicyPermission{data.PermissionRead},
)
if !allowed {
responseBody := net.MarshalBody(reqres.PolicyReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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/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"
)
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
default:
return net.Fallback
}
}
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/app/nexus/internal/env"
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,
) error {
return net.RouteFactory[url.APIAction](
url.APIURL(r.URL.Path),
url.APIAction(r.URL.Query().Get(url.KeyAPIAction)),
r.Method,
func(a url.APIAction, p url.APIURL) net.Handler {
// Lite: requires root key.
// SQLite: requires root key.
// Memory: does not require root key.
emptyRootKey := state.RootKeyZero()
inMemoryMode := env.BackendStoreType() == env.Memory
hasBackingStore := env.BackendStoreType() != 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.
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 cipher
import (
"fmt"
"io"
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// RouteDecrypt handles HTTP requests to decrypt ciphertext data using the
// SPIKE Nexus's 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.
//
// Errors:
// - Returns ErrReadFailure if request body cannot be read
// - Returns ErrParseFailure if JSON request cannot be parsed
// - Returns ErrBadInput if version is not supported or nonce size is invalid
// - Returns ErrInternal if cipher is unavailable or decryption fails
// - Returns appropriate HTTP status codes for different error conditions
func RouteDecrypt(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeDecrypt"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
// Check if streaming mode based on Content-Type
contentType := r.Header.Get("Content-Type")
streamModeActive := contentType == "application/octet-stream"
// Get cipher early as both modes need it
c := persist.Backend().GetCipher()
if c == nil {
if streamModeActive {
http.Error(w, "cipher not available", http.StatusInternalServerError)
return fmt.Errorf("cipher not available")
}
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrInternal,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
return fmt.Errorf("cipher not available")
}
var version byte
var nonce []byte
var ciphertext []byte
if streamModeActive {
err := guardDecryptCipherRequest(reqres.CipherDecryptRequest{}, w, r)
if err != nil {
return err
}
// Streaming mode - read the version, nonce, then ciphertext
ver := make([]byte, 1)
n, err := io.ReadFull(r.Body, ver)
if err != nil || n != 1 {
log.Log().Debug(fName, "message", "Failed to read version")
http.Error(w, "failed to read version", http.StatusBadRequest)
return fmt.Errorf("failed to read version")
}
version = ver[0]
// Read nonce
bytesToRead := c.NonceSize()
nonce = make([]byte, bytesToRead)
n, err = io.ReadFull(r.Body, nonce)
if err != nil || n != bytesToRead {
log.Log().Debug(fName, "message", "Failed to read nonce")
http.Error(w, "failed to read nonce", http.StatusBadRequest)
return fmt.Errorf("failed to read nonce")
}
// Read the remaining body as ciphertext
ciphertext = net.ReadRequestBody(w, r)
if ciphertext == nil {
return apiErr.ErrReadFailure
}
} else {
// JSON mode - parse request
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return apiErr.ErrReadFailure
}
request := net.HandleRequest[
reqres.CipherDecryptRequest, reqres.CipherDecryptResponse](
requestBody, w,
reqres.CipherDecryptResponse{Err: data.ErrBadInput},
)
if request == nil {
return apiErr.ErrParseFailure
}
err := guardDecryptCipherRequest(*request, w, r)
if err != nil {
return err
}
version = request.Version
nonce = request.Nonce
ciphertext = request.Ciphertext
}
// Validate version
if version != byte('1') {
if streamModeActive {
http.Error(w, "unsupported version", http.StatusBadRequest)
return fmt.Errorf("unsupported version: %v", version)
}
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, w)
return fmt.Errorf("unsupported version: %v", version)
}
// Validate nonce size
if len(nonce) != c.NonceSize() {
if streamModeActive {
http.Error(w, "invalid nonce size", http.StatusBadRequest)
return fmt.Errorf("invalid nonce size: expected %d, got %d",
c.NonceSize(), len(nonce))
}
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, w)
return fmt.Errorf("invalid nonce size: expected %d, got %d",
c.NonceSize(), len(nonce))
}
// Decrypt the ciphertext
log.Log().Info(fName, "message",
fmt.Sprintf("Decrypt %d %d", len(nonce), len(ciphertext)),
)
plaintext, err := c.Open(nil, nonce, ciphertext, nil)
if err != nil {
log.Log().Info(fName, "message", fmt.Errorf("failed to decrypt %w", err))
if streamModeActive {
http.Error(w, "decryption failed", http.StatusBadRequest)
return fmt.Errorf("decryption failed: %w", err)
}
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrInternal,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, w)
return fmt.Errorf("decryption failed: %w", err)
}
if streamModeActive {
// Streaming response: raw plaintext
w.Header().Set("Content-Type", "application/octet-stream")
if _, err := w.Write(plaintext); err != nil {
return err
}
log.Log().Info(fName, "message", "Streaming decryption successful")
return nil
}
// JSON response
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Plaintext: plaintext,
Err: data.ErrSuccess,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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 (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardDecryptCipherRequest(
_ reqres.CipherDecryptRequest, w http.ResponseWriter, r *http.Request,
) error {
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
// Lite Workloads are always allowed:
allowed := false
if spiffeid.IsLiteWorkload(sid.String()) {
allowed = true
}
// If not, do a policy check to determine if the request is allowed:
if !allowed {
allowed = state.CheckAccess(
sid.String(),
auth.PathSystemCipherDecrypt,
[]data.PolicyPermission{data.PermissionExecute},
)
}
// If not, do a policy check to determine if the request is allowed:
if !allowed {
responseBody := net.MarshalBody(reqres.CipherDecryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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/rand"
"fmt"
"io"
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
"github.com/spiffe/spike/internal/journal"
"github.com/spiffe/spike/internal/net"
)
// RouteEncrypt handles HTTP requests to encrypt plaintext data using the
// SPIKE Nexus's 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.
//
// Errors:
// - Returns ErrReadFailure if the request body cannot be read
// - Returns ErrParseFailure if JSON request cannot be parsed
// - Returns ErrInternal if cipher is unavailable or nonce generation fails
// - Returns appropriate HTTP status codes for different error conditions
func RouteEncrypt(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeEncrypt"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
// Check if streaming mode based on Content-Type
contentType := r.Header.Get("Content-Type")
streamModeActive := contentType == "application/octet-stream"
// Get cipher early as both modes need it
c := persist.Backend().GetCipher()
if c == nil {
if streamModeActive {
http.Error(w, "cipher not available", http.StatusInternalServerError)
return fmt.Errorf("cipher not available")
}
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrInternal,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
return fmt.Errorf("cipher not available")
}
var plaintext []byte
if streamModeActive {
err := guardEncryptCipherRequest(reqres.CipherEncryptRequest{}, w, r)
if err != nil {
return err
}
// Streaming mode - read raw body
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return apiErr.ErrReadFailure
}
plaintext = requestBody
} else {
// JSON mode - parse request
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return apiErr.ErrReadFailure
}
request := net.HandleRequest[
reqres.CipherEncryptRequest, reqres.CipherEncryptResponse](
requestBody, w,
reqres.CipherEncryptResponse{Err: data.ErrBadInput},
)
if request == nil {
return apiErr.ErrParseFailure
}
err := guardEncryptCipherRequest(*request, w, r)
if err != nil {
return err
}
plaintext = request.Plaintext
}
// Generate nonce
nonce := make([]byte, c.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
if streamModeActive {
http.Error(w, "failed to generate nonce", http.StatusInternalServerError)
return fmt.Errorf("failed to generate nonce: %w", err)
}
// JSON response:
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrInternal,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
return fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt the plaintext
log.Log().Info(fName,
"message", fmt.Sprintf("Encrypt %d %d", len(nonce), len(plaintext)))
ciphertext := c.Seal(nil, nonce, plaintext, nil)
log.Log().Info(fName,
"message", fmt.Sprintf("len after %d %d", len(nonce), len(ciphertext)))
if streamModeActive {
// Streaming response: version + nonce + ciphertext
w.Header().Set("Content-Type", "application/octet-stream")
v := byte('1')
if _, err := w.Write([]byte{v}); err != nil {
return err
}
if _, err := w.Write(nonce); err != nil {
return err
}
if _, err := w.Write(ciphertext); err != nil {
return err
}
log.Log().Info(fName, "message", "Streaming encryption successful")
return nil
}
// JSON response
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Version: byte('1'),
Nonce: nonce,
Ciphertext: ciphertext,
Err: data.ErrSuccess,
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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 (
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardEncryptCipherRequest(
_ reqres.CipherEncryptRequest, w http.ResponseWriter, r *http.Request,
) error {
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
// Lite Workloads are always allowed:
allowed := false
if spiffeid.IsLiteWorkload(sid.String()) {
allowed = true
}
// If not, do a policy check to determine if the request is allowed:
if !allowed {
allowed = state.CheckAccess(
sid.String(),
auth.PathSystemCipherEncrypt,
[]data.PolicyPermission{data.PermissionExecute},
)
}
// If not, block the request:
if !allowed {
responseBody := net.MarshalBody(reqres.CipherEncryptResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
"github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
"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,
) error {
const fName = "routeRecover"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
log.Log().Warn(fName, "message", "requestBody is nil")
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.RecoverRequest, reqres.RecoverResponse](
requestBody, w,
reqres.RecoverResponse{Err: data.ErrBadInput},
)
if request == nil {
log.Log().Warn(fName, "message", "request is nil")
return errors.ErrParseFailure
}
err := guardRecoverRequest(*request, w, r)
if err != nil {
return err
}
log.Log().Info(fName,
"message", "request is valid. Recovery shards requested.")
shards := recovery.NewPilotRecoveryShards()
// Security: reset shards before the function exits.
defer func() {
for i := range shards {
mem.ClearRawBytes(shards[i])
}
}()
if len(shards) < env.ShamirThreshold() {
log.Log().Error(fName, "message", "not enough shards. Exiting.")
return errors.ErrNotFound
}
// Track seen indices to check for duplicates
seenIndices := make(map[int]bool)
for idx, shard := range shards {
if seenIndices[idx] {
log.Log().Error(fName, "message", "duplicate index. Exiting.")
// Duplicate index.
return errors.ErrInvalidInput
}
// 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 {
log.Log().Error(fName, "message", "nil shard. Exiting.")
return errors.ErrInvalidInput
}
// Check for empty shards (all zeros)
zeroed := true
for _, b := range *shard {
if b != 0 {
zeroed = false
break
}
}
if zeroed {
log.Log().Error(fName, "message", "zeroed shard. Exiting.")
return errors.ErrInvalidInput
}
// Verify shard index is within valid range:
if idx < 1 || idx > env.ShamirShares() {
log.Log().Error(fName, "message", "invalid index. Exiting.")
return errors.ErrInvalidInput
}
}
responseBody := net.MarshalBody(reqres.RecoverResponse{
Shards: shards,
}, w)
// Security: Clean up response body before exit.
defer func() {
mem.ClearBytes(responseBody)
}()
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/internal/net"
)
func guardRecoverRequest(
_ reqres.RecoverRequest, w http.ResponseWriter, r *http.Request,
) error {
peerSPIFFEID, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if peerSPIFFEID == nil {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if !spiffeid.IsPilotRecover(peerSPIFFEID.String()) {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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/api/errors"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
"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 400 Bad Request: If all required shards have already been collected
// or if the provided shard is invalid.
// - HTTP 200 OK: If the shard is successfully added, including status
// information about the restoration progress.
//
// 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,
) error {
const fName = "routeRestore"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
if env.BackendStoreType() == env.Memory {
log.Log().Info(fName, "message", "skipping restoration in memory mode")
return nil
}
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.RestoreRequest, reqres.RestoreResponse](
requestBody, w,
reqres.RestoreResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
err := guardRestoreRequest(*request, w, r)
if err != nil {
return err
}
shardsMutex.Lock()
defer shardsMutex.Unlock()
// Check if we already have enough shards
currentShardCount := len(shards)
if currentShardCount >= env.ShamirThreshold() {
responseBody := net.MarshalBody(reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: 0,
Restored: true,
},
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, w)
return nil
}
for _, shard := range shards {
if int(shard.ID) != request.ID {
continue
}
// Duplicate shard found.
responseBody := net.MarshalBody(reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: env.ShamirThreshold() - currentShardCount,
Restored: currentShardCount == env.ShamirThreshold(),
},
Err: data.ErrBadInput,
}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusBadRequest, responseBody, 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
if currentShardCount == env.ShamirThreshold() {
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
}
}
responseBody := net.MarshalBody(reqres.RestoreResponse{
RestorationStatus: data.RestorationStatus{
ShardsCollected: currentShardCount,
ShardsRemaining: env.ShamirThreshold() - currentShardCount,
Restored: currentShardCount == env.ShamirThreshold(),
},
}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/internal/net"
)
func guardRestoreRequest(
request reqres.RestoreRequest, w http.ResponseWriter, r *http.Request,
) error {
peerSPIFFEID, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if peerSPIFFEID == nil {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if !spiffeid.IsPilotRestore(peerSPIFFEID.String()) {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
// It's unlikely to have 1000 SPIKE Keepers across the board.
// The indexes start from 1 and increase one-by-one by design.
const maxShardID = 1000
if request.ID < 1 || request.ID > maxShardID {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrInvalidInput
}
allZero := true
for _, b := range request.Shard {
if b != 0 {
allZero = false
break
}
}
if allZero {
responseBody := net.MarshalBody(reqres.RestoreResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrInvalidInput
}
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/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
"github.com/spiffe/spike-sdk-go/api/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"
)
// RouteDeleteSecret handles HTTP DELETE requests for secret deletion
// operations. It validates the JWT token, 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, an empty
// slice is used.
//
// Parameters:
// - w: http.ResponseWriter for writing the HTTP response
// - r: *http.Request containing the incoming HTTP request details
// - audit: *journal.AuditEntry for logging audit information about the deletion
// operation
//
// Returns:
// - error: Returns nil on successful execution, or an error describing what
// went wrong
//
// The function performs the following steps:
// 1. Validates the JWT token against the admin token
// 2. Reads and parses the request body
// 3. Processes the secret deletion
// 4. Returns a JSON response
//
// Example request body:
//
// {
// "path": "secret/path",
// "versions": [1, 2, 3]
// }
//
// Possible errors:
// - "invalid or missing JWT token": When JWT validation fails
// - "failed to read request body": When request body cannot be read
// - "failed to parse request body": When request body is invalid
// - "failed to marshal response body": When response cannot be serialized
func RouteDeleteSecret(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeDeleteSecret"
journal.AuditRequest(fName, r, audit, journal.AuditDelete)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.SecretDeleteRequest, reqres.SecretDeleteResponse](
requestBody, w,
reqres.SecretDeleteResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
err := guardDeleteSecretRequest(*request, w, r)
if err != nil {
return err
}
path := request.Path
versions := request.Versions
if len(versions) == 0 {
versions = []int{}
}
err = state.DeleteSecret(path, versions)
if err != nil {
log.Log().Error(fName, "message", "Failed to delete secret", "err", err)
} else {
log.Log().Info(fName, "message", "Secret deleted")
}
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardDeleteSecretRequest(
request reqres.SecretDeleteRequest, w http.ResponseWriter, r *http.Request,
) error {
path := request.Path
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePath(path)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(),
path,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretDeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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 (
"errors"
"net/http"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/api/entity/v1/reqres"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/kv"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/internal/net"
)
func handleGetSecretError(err error, w http.ResponseWriter) error {
fName := "handleGetSecretError"
if errors.Is(err, kv.ErrItemNotFound) {
log.Log().Info(fName, "message", "Secret not found")
res := reqres.SecretReadResponse{Err: data.ErrNotFound}
responseBody := net.MarshalBody(res, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusNotFound, responseBody, w)
log.Log().Info("routeGetSecret", "message", "not found")
return nil
}
log.Log().Warn(fName, "message", "Failed to retrieve secret", "err", err)
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrInternal}, w,
)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusInternalServerError, responseBody, w)
log.Log().Error(fName, "message", data.ErrInternal)
return err
}
func handleGetSecretMetadataError(err error, w http.ResponseWriter) error {
fName := "handleGetSecretMetadataError"
if errors.Is(err, kv.ErrItemNotFound) {
log.Log().Info(fName, "message", "Secret not found")
res := reqres.SecretMetadataResponse{Err: data.ErrNotFound}
responseBody := net.MarshalBody(res, w)
if responseBody == nil {
return errors.New("failed to marshal response body")
}
net.Respond(http.StatusNotFound, responseBody, w)
return nil
}
log.Log().Info(fName, "message",
"Failed to retrieve secret", "err", err)
responseBody := net.MarshalBody(reqres.SecretMetadataResponse{
Err: "Internal server error"}, w,
)
if responseBody == nil {
return errors.New("failed to marshal response body")
}
net.Respond(http.StatusInternalServerError, responseBody, w)
return err
}
// \\ 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"
apiErr "github.com/spiffe/spike-sdk-go/api/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 a valid admin JWT token for authentication. The
// function retrieves a secret based on the provided path and optional version
// number. If no version is specified, the latest version is returned.
//
// The function follows these steps:
// 1. Validates the JWT token
// 2. Validates and unmarshals the request body
// 3. Attempts to retrieve the secret
// 4. Returns the secret data or an appropriate error response
//
// 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:
// - error: if an error occurs during request processing.
//
// 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: Invalid or missing JWT token
// - 400 Bad Request: Invalid request body
// - 404 Not Found: Secret doesn't exist at specified path/version
//
// All operations are logged using structured logging.
func RouteGetSecret(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeGetSecret"
journal.AuditRequest(fName, r, audit, journal.AuditRead)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return apiErr.ErrReadFailure
}
request := net.HandleRequest[
reqres.SecretReadRequest, reqres.SecretReadResponse](
requestBody, w,
reqres.SecretReadResponse{Err: data.ErrBadInput},
)
if request == nil {
return apiErr.ErrParseFailure
}
version := request.Version
path := request.Path
err := guardGetSecretRequest(*request, w, r)
if err != nil {
return err
}
secret, err := state.GetSecret(path, version)
if err == nil {
log.Log().Info(fName, "message", "Secret found")
}
if err != nil {
return handleGetSecretError(err, w)
}
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Secret: data.Secret{Data: secret},
}, w)
if responseBody == nil {
return apiErr.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info("routeGetSecret", "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardGetSecretRequest(
request reqres.SecretReadRequest, w http.ResponseWriter, r *http.Request,
) error {
path := request.Path
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePath(path)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(),
path,
[]data.PolicyPermission{data.PermissionRead},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretReadResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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"
"github.com/spiffe/spike-sdk-go/api/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"
)
// RouteListPaths handles requests to retrieve all available secret paths.
//
// This endpoint requires a valid admin JWT token for authentication.
// 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 the JWT token
// 2. Validates the request body format
// 3. Retrieves all secret paths from the state
// 4. Returns the list of paths
//
// 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:
// - error: if an error occurs during request processing.
//
// 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: Invalid or missing JWT token
// - 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,
) error {
const fName = "routeListPaths"
journal.AuditRequest(fName, r, audit, journal.AuditList)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.SecretListRequest, reqres.SecretListResponse](
requestBody, w,
reqres.SecretListResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
err := guardListSecretRequest(*request, w, r)
if err != nil {
return err
}
keys := state.ListKeys()
responseBody := net.MarshalBody(reqres.SecretListResponse{Keys: keys}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/config/auth"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardListSecretRequest(
_ reqres.SecretListRequest, w http.ResponseWriter, r *http.Request,
) error {
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.SecretListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
allowed := state.CheckAccess(
sid.String(), auth.PathSystemSecretAccess,
[]data.PolicyPermission{data.PermissionList},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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"
)
func toSecretMetadataResponse(
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,
},
},
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package secret
import (
"errors"
"net/http"
"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/log"
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 a secret metadata at a
// specific path and version.
//
// This endpoint requires a valid admin JWT token for authentication. The
// function retrieves a secret based on the provided path and optional version
// number. If no version is specified, the latest version is returned.
//
// The function follows these steps:
// 1. Validates the JWT token
// 2. Validates and unmarshal 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
// - audit: *journal.AuditEntry for logging audit information
//
// Returns:
// - error: if an error occurs during request processing.
//
// Request body format:
//
// {
// "path": string, // Path to the secret
// "version": int // Optional: specific version to retrieve
// }
//
// 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:
// - 401 Unauthorized: Invalid or missing JWT token
// - 400 Bad Request: Invalid request body
// - 404 Not Found: Secret doesn't exist at specified path/version
//
// All operations are logged using structured logging.
func RouteGetSecretMetadata(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeGetSecretMetadata"
log.Log().Info(fName,
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery,
)
audit.Action = journal.AuditRead
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.New("failed to read request body")
}
request := net.HandleRequest[
reqres.SecretMetadataRequest, reqres.SecretMetadataResponse](
requestBody, w,
reqres.SecretMetadataResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.New("failed to parse request body")
}
err := guardGetSecretMetadataRequest(*request, w, r)
if err != nil {
return err
}
path := request.Path
version := request.Version
rawSecret, err := state.GetRawSecret(path, version)
if err != nil {
return handleGetSecretMetadataError(err, w)
}
response := toSecretMetadataResponse(rawSecret)
responseBody := net.MarshalBody(response, w)
if responseBody == nil {
return errors.New("failed to marshal response body")
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info("routeGetSecret", "message", "OK")
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardGetSecretMetadataRequest(
request reqres.SecretMetadataRequest, w http.ResponseWriter, r *http.Request,
) error {
path := request.Path
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretMetadataResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.SecretMetadataResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretMetadataResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePath(path)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretMetadataResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(), path,
[]data.PolicyPermission{data.PermissionRead},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretListResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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"
"github.com/spiffe/spike-sdk-go/api/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"
)
// RoutePutSecret handles HTTP requests to create or update secrets at a
// specified path.
//
// This endpoint requires a valid admin JWT token for authentication. 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:
// - error: if an error occurs during request processing.
//
// 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: Invalid or missing JWT token
//
// The function logs its progress at various stages using structured logging.
func RoutePutSecret(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routePutSecret"
journal.AuditRequest(fName, r, audit, journal.AuditCreate)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
request := net.HandleRequest[
reqres.SecretPutRequest, reqres.SecretPutResponse](
requestBody, w,
reqres.SecretPutResponse{Err: data.ErrBadInput},
)
if request == nil {
return errors.ErrParseFailure
}
err := guardPutSecretMetadataRequest(*request, w, r)
if err != nil {
return err
}
values := request.Values
path := request.Path
err = state.UpsertSecret(path, values)
if err != nil {
return err
}
log.Log().Info(fName, "message", "Secret upserted")
responseBody := net.MarshalBody(reqres.SecretPutResponse{}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"github.com/spiffe/spike/internal/net"
)
func guardPutSecretMetadataRequest(
request reqres.SecretPutRequest, w http.ResponseWriter, r *http.Request,
) error {
values := request.Values
path := request.Path
spiffeid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
if spiffeid == nil {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(spiffeid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
err = validation.ValidatePath(path)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return apiErr.ErrInvalidInput
}
for k := range values {
err := validation.ValidateName(k)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrInvalidInput
}
}
allowed := state.CheckAccess(
spiffeid.String(), path,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretPutResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return apiErr.ErrUnauthorized
}
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"
"github.com/spiffe/spike-sdk-go/api/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"
)
// RouteUndeleteSecret handles HTTP requests to restore previously deleted
// secrets.
//
// This endpoint requires a valid admin JWT token for authentication. 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 JWT, reads and unmarshals the request body,
// 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:
// - error: if an error occurs during request processing.
//
// 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: Invalid or missing JWT token
//
// The function logs its progress at various stages using structured logging.
func RouteUndeleteSecret(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
const fName = "routeUndeleteSecret"
journal.AuditRequest(fName, r, audit, journal.AuditUndelete)
requestBody := net.ReadRequestBody(w, r)
if requestBody == nil {
return errors.ErrReadFailure
}
req := net.HandleRequest[
reqres.SecretUndeleteRequest, reqres.SecretUndeleteResponse](
requestBody, w,
reqres.SecretUndeleteResponse{Err: data.ErrBadInput},
)
if req == nil {
return errors.ErrParseFailure
}
err := guardSecretUndeleteRequest(*req, w, r)
if err != nil {
return err
}
path := req.Path
versions := req.Versions
if len(versions) == 0 {
versions = []int{}
}
err = state.UndeleteSecret(path, versions)
if err != nil {
log.Log().Error(fName, "message", "Failed to undelete secret", "err", err)
} else {
log.Log().Info(fName, "message", "Secret undeleted")
}
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{}, w)
if responseBody == nil {
return errors.ErrMarshalFailure
}
net.Respond(http.StatusOK, responseBody, w)
log.Log().Info(fName, "message", data.ErrSuccess)
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/errors"
state "github.com/spiffe/spike/app/nexus/internal/state/base"
"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/spiffe"
"github.com/spiffe/spike-sdk-go/validation"
"github.com/spiffe/spike/internal/net"
)
func guardSecretUndeleteRequest(
request reqres.SecretUndeleteRequest, w http.ResponseWriter, r *http.Request,
) error {
path := request.Path
sid, err := spiffe.IDFromRequest(r)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return errors.ErrUnauthorized
}
if sid == nil {
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return errors.ErrUnauthorized
}
err = validation.ValidateSPIFFEID(sid.String())
if err != nil {
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return errors.ErrUnauthorized
}
err = validation.ValidatePath(path)
if err != nil {
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{
Err: data.ErrBadInput,
}, w)
net.Respond(http.StatusBadRequest, responseBody, w)
return errors.ErrInvalidInput
}
allowed := state.CheckAccess(
sid.String(),
path,
[]data.PolicyPermission{data.PermissionWrite},
)
if !allowed {
responseBody := net.MarshalBody(reqres.SecretUndeleteResponse{
Err: data.ErrUnauthorized,
}, w)
net.Respond(http.StatusUnauthorized, responseBody, w)
return errors.ErrUnauthorized
}
return nil
}
// \\ 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"
"fmt"
"github.com/spiffe/spike-sdk-go/crypto"
"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 storage. It uses AES-GCM
type Store struct {
noop.Store // No need to use a store; this is an encryption-as-a-service.
Cipher cipher.AEAD // Encryption Cipher for data protection
}
// New creates a new Backend with AES-GCM encryption using the provided key.
// Returns an error if cipher initialization fails.
func New(rootKey *[crypto.AES256KeySize]byte) (backend.Backend, error) {
block, err := aes.NewCipher(rootKey[:])
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
return &Store{
Cipher: gcm,
}, nil
}
// GetCipher returns the encryption cipher used for data protection.
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 implements an in-memory storage backend for managing
// secrets and policies in the SPIKE system. This package provides a
// fully functional in-memory implementation, `Store`, which is suitable
// for development, testing, or scenarios where persistent storage is
// not required.
//
// The `Store` provides thread-safe implementations for interfaces related
// to storing and retrieving secrets and policies. Unlike the noop backend,
// this implementation actually stores data in memory using the kv package
// and maintains the proper state throughout the application lifecycle.
package memory
import (
"context"
"crypto/cipher"
"errors"
"fmt"
"sync"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/kv"
)
// Store provides an in-memory implementation of a storage backend.
// This implementation actually stores data in memory using the kv package,
// suitable for development, testing, or scenarios where persistence isn't needed.
type Store struct {
secretStore *kv.KV
secretMu sync.RWMutex
policies map[string]*data.Policy
policyMu sync.RWMutex
cipher cipher.AEAD
}
// NewInMemoryStore creates a new in-memory store instance
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.
func (s *Store) Initialize(_ context.Context) error {
// Already initialized in constructor
return nil
}
// Close implements the closing operation for the store.
func (s *Store) Close(_ context.Context) error {
// Nothing to close for in-memory store
return nil
}
// StoreSecret saves a secret to the store at the specified path.
func (s *Store) StoreSecret(
_ context.Context, path string, secret kv.Value,
) error {
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.
func (s *Store) LoadSecret(
_ context.Context, path string,
) (*kv.Value, error) {
s.secretMu.RLock()
defer s.secretMu.RUnlock()
rawSecret, err := s.secretStore.GetRawSecret(path)
if err != nil && errors.Is(err, kv.ErrItemNotFound) {
// To align with the SQLite implementation, don't return an error for
// "not found" items and just return a `nil` secret.
return nil, nil
} else if err != nil {
return nil, err
}
return rawSecret, nil
}
// LoadAllSecrets retrieves all secrets stored in the store.
func (s *Store) LoadAllSecrets(_ context.Context) (
map[string]*kv.Value, error,
) {
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.
func (s *Store) StorePolicy(_ context.Context, policy data.Policy) error {
s.policyMu.Lock()
defer s.policyMu.Unlock()
if policy.ID == "" {
return fmt.Errorf("policy ID cannot be empty")
}
s.policies[policy.ID] = &policy
return nil
}
// LoadPolicy retrieves a policy from the store by its ID.
func (s *Store) LoadPolicy(
_ context.Context, id string,
) (*data.Policy, error) {
s.policyMu.RLock()
defer s.policyMu.RUnlock()
policy, exists := s.policies[id]
if !exists {
return nil, nil // Return nil, nil for not found (matching Store behavior)
}
return policy, nil
}
// LoadAllPolicies retrieves all policies from the store.
func (s *Store) LoadAllPolicies(
_ context.Context,
) (map[string]*data.Policy, error) {
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.
func (s *Store) DeletePolicy(_ context.Context, id string) error {
s.policyMu.Lock()
defer s.policyMu.Unlock()
delete(s.policies, id)
return nil
}
// GetCipher returns the cipher used for encryption/decryption
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 _, err := rand.Read(key); err != nil {
t.Fatalf("Failed to generate test key: %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("Failed to create cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("Failed to create GCM: %v", err)
}
return gcm
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
// Package noop implements an in-memory storage backend for managing
// secrets and policies in the SPIKE system. This package includes a
// no-op implementation, `Store`, which acts as a placeholder or
// testing tool for scenarios where persistent storage isn't required.
//
// The `Store` provides implementations for interfaces related to
// storing and retrieving secrets and policies but does not perform
// any actual storage operations. All methods in `Store` are no-ops and
// always return `nil` or equivalent default values.
package noop
import (
"context"
"crypto/cipher"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"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 kv.
// This implementation is a no-op and always returns nil.
func (s *Store) Close(_ context.Context) error {
return nil
}
// Initialize prepares the kv for use.
// This implementation is a no-op and always returns nil.
func (s *Store) Initialize(_ context.Context) error {
return nil
}
// LoadSecret retrieves a secret from the kv by its path.
// This implementation always returns nil secret and nil error.
func (s *Store) LoadSecret(
_ context.Context, _ string,
) (*kv.Value, error) {
return nil, nil
}
// LoadAllSecrets retrieves all secrets stored in the kv.
// This implementation always returns nil and nil error.
func (s *Store) LoadAllSecrets(_ context.Context) (
map[string]*kv.Value, error,
) {
return nil, nil
}
// StoreSecret saves a secret to the kv at the specified path.
// This implementation is a no-op and always returns nil.
func (s *Store) StoreSecret(
_ context.Context, _ string, _ kv.Value,
) error {
return nil
}
// StorePolicy stores a policy in the no-op kv.
// This implementation is a no-op and always returns nil.
func (s *Store) StorePolicy(_ context.Context, _ data.Policy) error {
return nil
}
// LoadPolicy retrieves a policy from the kv by its ID.
// This implementation always returns nil and nil error.
func (s *Store) LoadPolicy(
_ context.Context, _ string,
) (*data.Policy, error) {
return nil, nil
}
// LoadAllPolicies retrieves all policies from the no-op store.
// This implementation always returns nil and nil error.
func (s *Store) LoadAllPolicies(
_ context.Context,
) (map[string]*data.Policy, error) {
return nil, nil
}
// DeletePolicy removes a policy from the no-op kv by its ID.
// This implementation is a no-op and always returns nil.
func (s *Store) DeletePolicy(_ context.Context, _ string) error {
return nil
}
// GetCipher returns the cipher used for encryption/decryption.
// This implementation 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"
"fmt"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
"github.com/spiffe/spike/app/nexus/internal/state/backend/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 16, 24, or 32 bytes in length (for AES-128,
// AES-192, or AES-256 respectively).
//
// Returns an error if:
// - The options are invalid
// - The encryption key is malformed or has an invalid length
// - The cipher initialization fails
func New(cfg backend.Config) (backend.Backend, error) {
opts, err := persist.ParseOptions(cfg.Options)
if err != nil {
return nil, fmt.Errorf("invalid sqlite options: %w", err)
}
key, err := hex.DecodeString(cfg.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("invalid encryption key: %w", err)
}
// Validate key length
if len(key) != crypto.AES256KeySize {
return nil, fmt.Errorf(
"invalid encryption key length: must be 32 bytes",
)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
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/rand"
"fmt"
"io"
)
// encrypt encrypts the given data using the DataStore's cipher.
// It generates a random nonce and returns the ciphertext, nonce, and any
// error that occurred during encryption.
func (s *DataStore) encrypt(data []byte) ([]byte, []byte, error) {
nonce := make([]byte, s.Cipher.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("failed to generate nonce: %w", err)
}
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. It returns the plaintext and any error that
// occurred during decryption.
func (s *DataStore) decrypt(ciphertext, nonce []byte) ([]byte, error) {
plaintext, err := s.Cipher.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt data: %w", err)
}
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"
"crypto/cipher"
"database/sql"
"errors"
"fmt"
"path/filepath"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/env"
)
// Initialize prepares the DataStore for use by:
// - Creating the necessary data directory
// - Opening the SQLite database connection
// - Configuring connection pool settings
// - Creating required database tables
//
// It returns an error if:
// - The backend is already initialized
// - The data directory creation fails
// - The database connection fails
// - Table creation fails
//
// This method is thread-safe.
func (s *DataStore) Initialize(ctx context.Context) error {
const fName = "Initialize"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
return errors.New("backend already initialized")
}
if err := s.createDataDir(); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
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 {
return fmt.Errorf("failed to open database: %w", err)
}
// 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.DatabaseSkipSchemaCreation() {
s.db = db
return nil
}
// Create tables
if err := s.createTables(ctx, db); err != nil {
closeErr := db.Close()
if closeErr != nil {
return closeErr
}
return fmt.Errorf("failed to create tables: %w", 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.
//
// Returns any error encountered while closing the database connection.
func (s *DataStore) Close(_ context.Context) error {
var err error
s.closeOnce.Do(func() {
err = s.db.Close()
})
return err
}
// GetCipher retrieves the AEAD cipher instance from the DataStore.
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 (
"time"
"github.com/spiffe/spike/app/nexus/internal/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
}
// DefaultOptions returns the default SQLite options
func DefaultOptions() *Options {
return &Options{
DataDir: ".data",
DatabaseFile: "spike.db",
JournalMode: env.DatabaseJournalMode(),
BusyTimeoutMs: env.DatabaseBusyTimeoutMs(),
MaxOpenConns: env.DatabaseMaxOpenConns(),
MaxIdleConns: env.DatabaseMaxIdleConns(),
ConnMaxLifetime: env.DatabaseConnMaxLifetimeSec(),
}
}
// \\ 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"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
)
// ParseOptions parses and validates the provided backend options
func ParseOptions(opts map[backend.DatabaseConfigKey]any) (*Options, error) {
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 {
return nil,
fmt.Errorf(
"MaxIdleConns (%d) cannot be greater than MaxOpenConns (%d)",
sqliteOpts.MaxIdleConns, sqliteOpts.MaxOpenConns)
}
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"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
// DeletePolicy removes a policy from the database by its ID.
//
// Uses serializable transaction isolation to ensure consistency.
// Automatically rolls back on error.
//
// Parameters:
// - ctx: Context for the database operation
// - id: Unique identifier of the policy to delete
//
// Returns error if:
// - Transaction operations fail
// - Policy deletion fails
func (s *DataStore) DeletePolicy(ctx context.Context, id string) error {
const fName = "DeletePolicy"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
committed := false
defer func(tx *sql.Tx) {
if !committed {
err := tx.Rollback()
if err != nil {
fmt.Printf("failed to rollback transaction: %v\n", err)
}
}
}(tx)
_, err = tx.ExecContext(ctx, ddl.QueryDeletePolicy, id)
if err != nil {
return fmt.Errorf("failed to delete policy: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
committed = true
return nil
}
func generateNonce(s *DataStore) ([]byte, error) {
nonce := make([]byte, s.Cipher.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return nonce, nil
}
func encryptWithNonce(s *DataStore, nonce []byte, data []byte) ([]byte, error) {
if len(nonce) != s.Cipher.NonceSize() {
return nil, fmt.Errorf("invalid nonce size: got %d, want %d", len(nonce), s.Cipher.NonceSize())
}
ciphertext := s.Cipher.Seal(nil, nonce, data, nil)
return ciphertext, 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 error if:
// - Transaction operations fail
// - Policy storage fails
func (s *DataStore) StorePolicy(ctx context.Context, policy data.Policy) error {
const fName = "StorePolicy"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
committed := false
defer func(tx *sql.Tx) {
if !committed {
err := tx.Rollback()
if err != nil {
fmt.Printf("failed to rollback transaction: %v\n", err)
}
}
}(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, err := generateNonce(s)
if err != nil {
return fmt.Errorf("failed to generate nonce: %w", err)
}
encryptedSpiffeID, err := encryptWithNonce(s, nonce, []byte(policy.SPIFFEIDPattern))
if err != nil {
return fmt.Errorf("failed to encrypt SPIFFE ID: %w", err)
}
encryptedPathPattern, err := encryptWithNonce(s, nonce, []byte(policy.PathPattern))
if err != nil {
return fmt.Errorf("failed to encrypt path pattern: %w", err)
}
encryptedPermissions, err := encryptWithNonce(s, nonce, []byte(permissionsStr))
if err != nil {
return fmt.Errorf("failed to encrypt permissions: %w", err)
}
_, err = tx.ExecContext(ctx, ddl.QueryUpsertPolicy,
policy.ID,
policy.Name,
nonce,
encryptedSpiffeID,
encryptedPathPattern,
encryptedPermissions,
time.Now().Unix(),
)
if err != nil {
return fmt.Errorf("failed to upsert policy: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
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
// - error: Database errors or pattern compilation errors
func (s *DataStore) LoadPolicy(
ctx context.Context, id string,
) (*data.Policy, error) {
const fName = "LoadPolicy"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.RLock()
defer s.mu.RUnlock()
var policy data.Policy
var encryptedSPIFFEIDPattern, encryptedPathPattern, encryptedPermissions, nonce []byte
var createdTime int64
err := s.db.QueryRowContext(ctx, ddl.QueryLoadPolicy, id).Scan(
&policy.ID,
&policy.Name,
&encryptedSPIFFEIDPattern,
&encryptedPathPattern,
&encryptedPermissions,
&nonce,
&createdTime,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to load policy: %w", err)
}
// Decrypt
decryptedSPIFFEIDPattern, err := s.decrypt(encryptedSPIFFEIDPattern, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt SPIFFE ID pattern: %w", err)
}
decryptedPathPattern, err := s.decrypt(encryptedPathPattern, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt path pattern: %w", err)
}
decryptedPermissions, err := s.decrypt(encryptedPermissions, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt permissions: %w", err)
}
// Set decrypted values
policy.SPIFFEIDPattern = string(decryptedSPIFFEIDPattern)
policy.PathPattern = string(decryptedPathPattern)
policy.CreatedAt = time.Unix(createdTime, 0)
permissionsStr := string(decryptedPermissions)
if permissionsStr != "" {
perms := strings.Split(permissionsStr, ",")
policy.Permissions = make([]data.PolicyPermission, len(perms))
for i, p := range perms {
policy.Permissions[i] = data.PolicyPermission(strings.TrimSpace(p))
}
}
// Compile regex
policy.IDRegex, err = regexp.Compile(policy.SPIFFEIDPattern)
if err != nil {
return nil, fmt.Errorf("invalid spiffeid pattern: %w", err)
}
policy.PathRegex, err = regexp.Compile(policy.PathPattern)
if err != nil {
return nil, fmt.Errorf("invalid path pattern: %w", err)
}
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 they aren't wildcards (*).
//
// Parameters:
// - ctx: Context for the database operation
//
// Returns:
// - map[string]*data.Policy: Map of policy IDs to loaded policies with
// compiled patterns
// - error: Database errors or pattern compilation errors
func (s *DataStore) LoadAllPolicies(
ctx context.Context,
) (map[string]*data.Policy, error) {
const fName = "LoadAllPolicies"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.QueryContext(ctx, ddl.QueryAllPolicies)
if err != nil {
return nil, fmt.Errorf("failed to query policies: %w", err)
}
defer rows.Close()
policies := make(map[string]*data.Policy)
for rows.Next() {
var policy data.Policy
var encryptedSPIFFEIDPattern, encryptedPathPattern, encryptedPermissions, nonce []byte
var createdTime int64
if err := rows.Scan(
&policy.ID,
&policy.Name,
&encryptedSPIFFEIDPattern,
&encryptedPathPattern,
&encryptedPermissions,
&nonce,
&createdTime,
); err != nil {
return nil, fmt.Errorf("failed to scan policy: %w", err)
}
// Decrypt
decryptedSPIFFEIDPattern, err := s.decrypt(encryptedSPIFFEIDPattern, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt SPIFFE ID pattern for policy %s: %w", policy.ID, err)
}
decryptedPathPattern, err := s.decrypt(encryptedPathPattern, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt path pattern for policy %s: %w", policy.ID, err)
}
decryptedPermissions, err := s.decrypt(encryptedPermissions, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt permissions for policy %s: %w", policy.ID, err)
}
policy.SPIFFEIDPattern = string(decryptedSPIFFEIDPattern)
policy.PathPattern = string(decryptedPathPattern)
policy.CreatedAt = time.Unix(createdTime, 0)
// Deserialize permissions
permissionsStr := string(decryptedPermissions)
if permissionsStr != "" {
perms := strings.Split(permissionsStr, ",")
policy.Permissions = make([]data.PolicyPermission, len(perms))
for i, p := range perms {
policy.Permissions[i] = data.PolicyPermission(strings.TrimSpace(p))
}
}
// Compile regex
policy.IDRegex, err = regexp.Compile(policy.SPIFFEIDPattern)
if err != nil {
return nil, fmt.Errorf("invalid spiffeid pattern for policy %s: %w", policy.ID, err)
}
policy.PathRegex, err = regexp.Compile(policy.PathPattern)
if err != nil {
return nil, fmt.Errorf("invalid path pattern for policy %s: %w", policy.ID, err)
}
policies[policy.ID] = &policy
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate policy rows: %w", err)
}
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 (
"context"
"database/sql"
"os"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app/nexus/internal/state/backend/sqlite/ddl"
)
func (s *DataStore) createDataDir() error {
return os.MkdirAll(s.Opts.DataDir, 0750)
}
func (s *DataStore) createTables(ctx context.Context, db *sql.DB) error {
const fName = "createTables"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
_, err := db.ExecContext(ctx, ddl.QueryInitialize)
return err
}
// \\ 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"
"fmt"
"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"
)
// 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.
//
// Returns an error if:
// - The transaction fails to begin or commit
// - Data marshaling fails
// - Encryption fails
// - Database operations fail
//
// This method is thread-safe.
func (s *DataStore) StoreSecret(
ctx context.Context, path string, secret kv.Value,
) error {
const fName = "StoreSecret"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
committed := false
defer func(tx *sql.Tx) {
if !committed {
err := tx.Rollback()
if err != nil {
fmt.Printf("failed to rollback transaction: %v\n", err)
}
}
}(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 fmt.Errorf("failed to kv secret metadata: %w", err)
}
// Update versions
for version, sv := range secret.Versions {
data, err := json.Marshal(sv.Data)
if err != nil {
return fmt.Errorf("failed to marshal secret values: %w", err)
}
encrypted, nonce, err := s.encrypt(data)
if err != nil {
return fmt.Errorf("failed to encrypt secret data: %w", err)
}
_, err = tx.ExecContext(ctx, ddl.QueryUpsertSecret,
path, version, nonce, encrypted, sv.CreatedTime, sv.DeletedTime)
if err != nil {
return fmt.Errorf("failed to kv secret version: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", 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
//
// Returns:
// - (nil, nil) if the secret doesn't exist
// - (nil, error) if any operation fails
// - (*kv.Secret, nil) with the decrypted secret and all its versions on
// success
//
// This method is thread-safe.
func (s *DataStore) LoadSecret(
ctx context.Context, path string,
) (*kv.Value, error) {
const fName = "LoadSecret"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
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.
// If an error occurs during the retrieval process, it returns nil and the
// error. This method acquires a read lock to ensure consistent access to the
// database.
//
// Contexts that are canceled or reach their deadline will result in the
// operation being interrupted early and returning 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, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Get all secret paths
rows, err := s.db.QueryContext(ctx, ddl.QueryPathsFromMetadata)
if err != nil {
return nil, fmt.Errorf("failed to query secret paths: %w", err)
}
defer func(rows *sql.Rows) {
err := rows.Close()
if err != nil {
fmt.Printf("failed to close rows: %v\n", err)
}
}(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 {
return nil, fmt.Errorf("failed to scan path: %w", err)
}
// Load the full secret for this path
secret, err := s.loadSecretInternal(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to load secret at path %s: %w", path, err)
}
if secret != nil {
secrets[path] = secret
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate secret paths: %w", 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"
"fmt"
"time"
"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"
)
// 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.
// Returns nil if the secret does not exist.
// - error: An error if any database or decryption operation fails.
// Returns nil error with nil secret for non-existent paths.
//
// Special behavior:
// - Returns (nil, nil) when the secret doesn't exist (sql.ErrNoRows)
// - Returns (nil, error) for actual errors (database, decryption,
// unmarshaling)
// - 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, error) {
const fName = "loadSecretInternal"
if ctx == nil {
log.FatalLn(fName, "message", "nil context")
}
var secret kv.Value
// Load metadata
err := s.db.QueryRowContext(ctx, ddl.QuerySecretMetadata, path).Scan(
&secret.Metadata.CurrentVersion,
&secret.Metadata.OldestVersion,
&secret.Metadata.CreatedTime,
&secret.Metadata.UpdatedTime,
&secret.Metadata.MaxVersions)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to load secret metadata: %w", err)
}
// Load versions
rows, err := s.db.QueryContext(ctx, ddl.QuerySecretVersions, path)
if err != nil {
return nil, fmt.Errorf("failed to query secret versions: %w", err)
}
defer func(rows *sql.Rows) {
err := rows.Close()
if err != nil {
fmt.Printf("failed to close rows: %v\n", err)
}
}(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 err := rows.Scan(
&version, &nonce,
&encrypted, &createdTime, &deletedTime,
); err != nil {
return nil, fmt.Errorf("failed to scan secret version: %w", err)
}
decrypted, err := s.decrypt(encrypted, nonce)
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret version: %w", err)
}
var values map[string]string
if err := json.Unmarshal(decrypted, &values); err != nil {
return nil, fmt.Errorf("failed to unmarshal secret values: %w", err)
}
sv := kv.Version{
Data: values,
CreatedTime: createdTime,
}
if deletedTime.Valid {
sv.DeletedTime = &deletedTime.Time
}
secret.Versions[version] = sv
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate secret versions: %w", err)
}
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, err := aes.NewCipher(rootKey[:])
if err != nil {
t.Fatalf("Failed to create cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("Failed to create GCM: %v", err)
}
// 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 err := store.Initialize(ctx); err != nil {
t.Fatalf("Failed to initialize datastore: %v", err)
}
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
_, err := store.db.ExecContext(ctx, ddl.QueryUpdateSecretMetadata,
path, metadata.CurrentVersion, metadata.OldestVersion,
metadata.CreatedTime, metadata.UpdatedTime, metadata.MaxVersions)
if err != nil {
t.Fatalf("Failed to insert metadata: %v", err)
}
// 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 _, err := rand.Read(nonce); err != nil {
t.Fatalf("Failed to generate nonce: %v", err)
}
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
}
_, err := store.db.ExecContext(ctx, ddl.QueryUpsertSecret,
path, version, nonce, encrypted, createdTime, deletedTime)
if err != nil {
t.Fatalf("Failed to insert version %d: %v", version, err)
}
}
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package base
import (
"sync"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/log"
)
// Global variables related to the root key with thread-safety protection.
var (
// rootKey is a 32-byte array that stores the cryptographic root key.
// It is initialized to zeroes by default.
rootKey [crypto.AES256KeySize]byte
// rootKeyMu provides mutual exclusion for access to the root key.
rootKeyMu sync.RWMutex
)
// 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.
//
// To ensure the system always has a legitimate root key, the operation is a
// no-op if rk is nil or zeroed out. When that happens, the function logs
// a warning.
//
// Parameters:
// - rk: Pointer to a 32-byte array containing the new root key value
func SetRootKey(rk *[crypto.AES256KeySize]byte) {
fName := "SetRootKey"
log.Log().Info(fName, "message", "Setting root key")
if rk == nil {
log.Log().Warn(fName, "message", "Root key is nil. Skipping update.")
return
}
if mem.Zeroed32(rk) {
log.Log().Warn(fName, "message", "Root key is zeroed. Skipping update.")
return
}
rootKeyMu.Lock()
defer rootKeyMu.Unlock()
for i := range rootKey {
rootKey[i] = rk[i]
}
log.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/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// Initialize initializes the backend storage with the provided root key.
//
// For non-"in-memory" backing stores, if the root key is nil or empty,
// the application will crash for security.
//
// Parameters:
// - r [32]byte: The root key to initialize the crypto state.
func Initialize(r *[crypto.AES256KeySize]byte) {
const fName = "Initialize"
// 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.BackendStoreType() == env.Memory {
log.Log().Info(fName, "message", "in-memory store. will not create root key")
return
}
if r == nil || mem.Zeroed32(r) {
log.FatalLn(fName, "message", "root key is nil or zeroed")
}
// Update the internal root key.
// Locks on a mutex; so only a single process can modify the root key.
SetRootKey(r)
}
// \\ 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"
"errors"
"fmt"
"regexp"
"time"
"github.com/google/uuid"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/spiffeid"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
var (
ErrPolicyNotFound = errors.New("policy not found")
ErrPolicyExists = errors.New("policy already exists")
ErrInvalidPolicy = errors.New("invalid policy")
)
// 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.Log().Warn(fName,
"message", "failed to load policies",
"err", err.Error(),
)
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 contains(policy.Permissions, data.PermissionSuper) {
return true
}
if hasAllPermissions(policy.Permissions, wants) {
return true
}
}
return false
}
// CreatePolicy creates a new policy in the system after validating and
// preparing it. The function compiles regex patterns, generates a UUID, and
// sets the creation timestamp before storing the policy.
//
// Parameters:
// - policy: The policy to create. Must have a non-empty Name field.
// SpiffeIdPattern and PathPattern MUST be valid regular expressions.
//
// Returns:
// - data.Policy: The created policy, including generated ID and timestamps
// - error: ErrInvalidPolicy if policy name is empty, or regex compilation
// errors for invalid patterns
//
// The function performs the following modifications to the input policy:
// - Compiles and stores regex patterns for non-wildcard SpiffeIdPattern
// and PathPattern
// - Generates and sets a new UUID as the policy ID
// - Sets CreatedAt to current time if not already set
func CreatePolicy(policy data.Policy) (data.Policy, error) {
if policy.Name == "" {
return data.Policy{}, ErrInvalidPolicy
}
ctx := context.Background()
// Check for duplicate policy name
allPolicies, err := persist.Backend().LoadAllPolicies(ctx)
if err != nil {
return data.Policy{}, fmt.Errorf("failed to load policies: %w", err)
}
for _, existingPolicy := range allPolicies {
if existingPolicy.Name == policy.Name {
return data.Policy{}, ErrPolicyExists
}
}
// Compile and validate patterns
idRegex, err := regexp.Compile(policy.SPIFFEIDPattern)
if err != nil {
return data.Policy{},
errors.Join(
ErrInvalidPolicy,
fmt.Errorf("%s: %v", "invalid spiffeid pattern", err),
)
}
policy.IDRegex = idRegex
pathRegex, err := regexp.Compile(policy.PathPattern)
if err != nil {
return data.Policy{},
errors.Join(
ErrInvalidPolicy,
fmt.Errorf("%s: %v", "invalid path pattern", err),
)
}
policy.PathRegex = pathRegex
// Generate ID and set creation time
policy.ID = uuid.New().String()
if policy.CreatedAt.IsZero() {
policy.CreatedAt = time.Now()
}
// Store directly to the backend
err = persist.Backend().StorePolicy(ctx, policy)
if err != nil {
return data.Policy{}, fmt.Errorf("failed to store policy: %w", err)
}
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
// - error: ErrPolicyNotFound if no policy exists with the given ID.
func GetPolicy(id string) (data.Policy, error) {
ctx := context.Background()
// Load directly from the backend
policy, err := persist.Backend().LoadPolicy(ctx, id)
if err != nil {
return data.Policy{}, fmt.Errorf("failed to load policy: %w", err)
}
if policy == nil {
return data.Policy{}, ErrPolicyNotFound
}
return *policy, nil
}
// DeletePolicy removes a policy from the system by its ID.
//
// Parameters:
// - id: The unique identifier of the policy to delete
//
// Returns:
// - error: ErrPolicyNotFound if no policy exists with the given ID,
// nil if the deletion was successful
func DeletePolicy(id string) error {
ctx := context.Background()
// Check if the policy exists first (to maintain the same error behavior)
policy, err := persist.Backend().LoadPolicy(ctx, id)
if err != nil {
return fmt.Errorf("failed to load policy: %w", err)
}
if policy == nil {
return ErrPolicyNotFound
}
// Delete the policy from the backend
err = persist.Backend().DeletePolicy(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete policy: %w", err)
}
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.
func ListPolicies() ([]data.Policy, error) {
ctx := context.Background()
// Load all policies from the backend
allPolicies, err := persist.Backend().LoadAllPolicies(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load policies: %w", err)
}
// 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.
func ListPoliciesByPathPattern(pathPattern string) ([]data.Policy, error) {
ctx := context.Background()
// Load all policies from the backend
allPolicies, err := persist.Backend().LoadAllPolicies(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load policies: %w", err)
}
// 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.
func ListPoliciesBySPIFFEIDPattern(SPIFFEIDPattern string) ([]data.Policy, error) {
ctx := context.Background()
// Load all policies from the backend.
allPolicies, err := persist.Backend().LoadAllPolicies(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load policies: %w", err)
}
// 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/kv"
"github.com/spiffe/spike/app/nexus/internal/env"
"github.com/spiffe/spike/app/nexus/internal/state/persist"
)
// UpsertSecret stores or updates a secret at the specified pathPattern 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:
// - pathPattern: The location where the secret should be stored
// - values: A map containing the secret key-value pairs to be stored
//
// Returns:
// - error: An error if the operation fails, 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) error {
ctx := context.Background()
// Load the current secret (if it exists) to handle versioning
// Backend does NOT return an error if the secret is not found and returns
// `nil` instead. Any other error means there is a problem with the
// backing store, so it's better to return it and exit the function.
currentSecret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return fmt.Errorf("failed to load current secret: %w", err)
}
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.MaxSecretVersions(), // Get from the environment
},
}
} else {
// Existing secret - increment version
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
versionsToDelete := len(currentSecret.Versions) - currentSecret.Metadata.MaxVersions
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 fmt.Errorf("failed to store secret: %w", err)
}
return nil
}
// DeleteSecret deletes one or more versions of a secret at the specified pathPattern.
// It acquires a mutex lock before performing the deletion to ensure thread
// safety.
//
// Parameters:
// - pathPattern: The pathPattern to the secret to be deleted
// - versions: A slice of version numbers to delete. If empty, deletes the
// current version only. Version number 0 is the current version.
func DeleteSecret(path string, versions []int) error {
ctx := context.Background()
// Load the current secret from the backing store
secret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return fmt.Errorf("failed to load secret: %w", err)
}
if secret == nil {
return fmt.Errorf("secret not found at pathPattern: %s", path)
}
// 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 && version < secret.Metadata.CurrentVersion {
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 fmt.Errorf("failed to store updated secret: %w", err)
}
return nil
}
// UndeleteSecret restores previously deleted versions of a secret at the
// specified pathPattern. It takes a pathPattern string identifying the secret's location and
// a slice of version numbers to restore. The function acquires a lock on the
// key-value kv to ensure thread-safe operations during the `undelete` process.
//
// The function operates synchronously and will block until the undelete
// operation is complete. If any specified version numbers don't exist or were
// not previously deleted, those versions will be silently skipped.
//
// Parameters:
// - pathPattern: The pathPattern to the secret to be restored
// - versions: A slice of integer version numbers to restore
//
// Example:
//
// // Restore versions 1 and 3 of a secret
// UndeleteSecret("app/secrets/api-key", []int{1, 3})
func UndeleteSecret(path string, versions []int) error {
ctx := context.Background()
// Load the current secret from the backing store
secret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return fmt.Errorf("failed to load secret: %w", err)
}
if secret == nil {
return fmt.Errorf("secret not found at pathPattern: %s", path)
}
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 {
return fmt.Errorf("no deleted versions to undelete at pathPattern: %s", path)
}
} 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 {
return fmt.Errorf("no versions were undeleted at pathPattern: %s", path)
}
// If CurrentVersion was 0 (all deleted), set it to
// the highest undeleted version
if secret.Metadata.CurrentVersion == 0 && highestUndeleted > 0 {
secret.Metadata.CurrentVersion = highestUndeleted
secret.Metadata.UpdatedTime = time.Now()
}
// Store the updated secret back to the backend
err = persist.Backend().StoreSecret(ctx, path, *secret)
if err != nil {
return fmt.Errorf("failed to store updated secret: %w", err)
}
return nil
}
// GetSecret retrieves a secret from the specified pathPattern and version.
// It provides thread-safe read access to the secret kv.
//
// Parameters:
// - pathPattern: The location of the secret to retrieve
// - version: The specific version of the secret to fetch
//
// Returns:
// - map[string]string: The secret key-value pairs
// - bool: Whether the secret was found
func GetSecret(path string, version int) (map[string]string, error) {
ctx := context.Background()
// Load secret from the backing store
secret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to load secret: %w", err)
}
if secret == nil {
return nil, fmt.Errorf("secret not found at pathPattern: %s", path)
}
// Handle version 0 (current version)
if version == 0 {
version = secret.Metadata.CurrentVersion
if version == 0 {
return nil, fmt.Errorf("no active versions for secret at pathPattern: %s", path)
}
}
// Get the specific version
v, exists := secret.Versions[version]
if !exists {
return nil, fmt.Errorf(
"version %d not found for secret at pathPattern: %s", version, path)
}
// Check if the version is deleted
if v.DeletedTime != nil {
return nil, fmt.Errorf(
"version %d is deleted for secret at pathPattern: %s", version, path)
}
return v.Data, nil
}
// GetRawSecret retrieves a secret with metadata from the specified pathPattern and
// version. It provides thread-safe read access to the backing store.
//
// Parameters:
// - pathPattern: The location of the secret to retrieve
// - version: The specific version of the secret to fetch
//
// Returns:
// - *kv.Secret: The secret type
// - bool: Whether the secret was found
func GetRawSecret(path string, version int) (*kv.Value, error) {
ctx := context.Background()
// Load secret from the backing store
secret, err := persist.Backend().LoadSecret(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to load secret: %w", err)
}
if secret == nil {
return nil, fmt.Errorf("secret not found at pathPattern: %s", path)
}
// Validate the requested version exists and is not deleted
checkVersion := version
if checkVersion == 0 {
checkVersion = secret.Metadata.CurrentVersion
if checkVersion == 0 {
return nil, fmt.Errorf("no active versions for secret at pathPattern: %s", path)
}
}
v, exists := secret.Versions[checkVersion]
if !exists {
return nil, fmt.Errorf("version %d not found for secret at pathPattern: %s", checkVersion, path)
}
if v.DeletedTime != nil {
return nil, fmt.Errorf("version %d is deleted for secret at pathPattern: %s", checkVersion, path)
}
// Return the full secret, but we've validated the requested version exists
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 (
"github.com/spiffe/spike-sdk-go/api/entity/data"
)
func contains(permissions []data.PolicyPermission,
permission data.PolicyPermission) bool {
for _, p := range permissions {
if p == permission {
return true
}
}
return false
}
func hasAllPermissions(
haves []data.PolicyPermission,
wants []data.PolicyPermission,
) bool {
// Super permission acts as a joker - 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. The
// function is thread-safe through a read mutex lock.
//
// 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
//
// The function is safe for concurrent access as it uses a read mutex to protect
// access to the backend instances. Unlike InitializeBackend, this function
// returns existing instances rather than creating new ones.
func Backend() backend.Backend {
backendMu.RLock()
defer backendMu.RUnlock()
return be
}
// \\ 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/crypto"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike/app/nexus/internal/env"
"github.com/spiffe/spike/app/nexus/internal/state/backend"
)
var be backend.Backend
func createCipher() cipher.AEAD {
key := make([]byte, crypto.AES256KeySize) // AES-256 key
if _, err := rand.Read(key); err != nil {
log.FatalLn("createCipher", "message", "Failed to generate test key", "err", err)
}
block, err := aes.NewCipher(key)
if err != nil {
log.FatalLn("createCipher", "message", "Failed to create cipher", "err", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
log.FatalLn("createCipher", "message", "Failed to create GCM", "err", err)
}
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"
log.Log().Info(fName,
"message", "Initializing backend", "storeType", env.BackendStoreType())
// 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.BackendStoreType() == env.Memory {
if rootKey != nil {
log.FatalLn(fName,
"message", "In-memory store can only be initialized with nil root key",
"err", "root key is not nil",
)
}
} else {
if rootKey == nil {
log.FatalLn(fName,
"message", "Failed to initialize backend",
"err", "root key is nil",
)
}
if mem.Zeroed32(rootKey) {
log.FatalLn(fName,
"message", "Failed to initialize backend",
"err", "root key is all zeroes",
)
}
}
backendMu.Lock()
defer backendMu.Unlock()
storeType := env.BackendStoreType()
switch storeType {
case env.Lite:
be = initializeLiteBackend(rootKey)
case env.Memory:
be = initializeInMemoryBackend()
case env.Sqlite:
be = initializeSqliteBackend(rootKey)
default:
be = initializeInMemoryBackend()
}
log.Log().Info(
fName, "message", "Backend initialized", "storeType", storeType,
)
}
// \\ 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:
// - A backend.Backend interface implementation if successful
// - nil if initialization fails
//
// Error Handling:
// If the backend creation fails, the function logs a warning and returns nil
// rather than propagating the error. This allows the system to gracefully
// degrade to using only in-memory state without blocking startup.
//
// Example:
//
// var rootKey [32]byte
// // ... populate rootKey with secure random data ...
// backend := initializeLiteBackend(&rootKey)
// if backend == nil {
// // Handle fallback to in-memory only operation
// }
//
// 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 {
log.FatalLn(fName, "message", "Failed to create Lite backend",
"err", err.Error(),
)
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 (
"github.com/spiffe/spike/app/nexus/internal/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.MaxSecretVersions())
}
// \\ 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/log"
"github.com/spiffe/spike/app/nexus/internal/env"
"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.DatabaseJournalMode()
opts[backend.KeyBusyTimeoutMs] = env.DatabaseBusyTimeoutMs()
opts[backend.KeyMaxOpenConns] = env.DatabaseMaxOpenConns()
opts[backend.KeyMaxIdleConns] = env.DatabaseMaxIdleConns()
opts[backend.KeyConnMaxLifetimeSeconds] = env.DatabaseConnMaxLifetimeSec()
// 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 {
// Log error but don't fail initialization
// The system can still work with just in-memory state
log.Log().Warn(fName,
"message", "Failed to create SQLite backend",
"err", err.Error(),
)
return nil
}
ctxC, cancel := context.WithTimeout(
context.Background(), env.DatabaseInitializationTimeout(),
)
defer cancel()
if err := dbBackend.Initialize(ctxC); err != nil {
log.Log().Warn(fName,
"message", "Failed to initialize SQLite backend",
"err", err.Error(),
)
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/log"
"github.com/spiffe/spike-sdk-go/security/mem"
"github.com/spiffe/spike-sdk-go/spiffe"
"github.com/spiffe/spike/app/spike/internal/cmd"
"github.com/spiffe/spike/app/spike/internal/env"
)
const appName = "SPIKE"
func main() {
if !mem.Lock() {
if env.ShowMemoryWarning() {
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 {
log.FatalLn(appName, "message", "failed to get source", "err", err.Error())
}
defer spiffe.CloseSource(source)
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 cmd
import (
"fmt"
"os"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"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 CLI command structure with a workload API X.509
// source.
//
// It creates and configures the following commands:
// - get: Retrieves secrets with optional version specification
// - delete: Removes specified versions of secrets
// - undelete: Restores specified versions of secrets
// - initialization: Initializes the secret management system
// - put: Stores new secrets
// - list: Displays available secrets
//
// Parameters:
// - source: An X.509 source for workload API authentication
//
// Each command is added to the root command with appropriate flags and options:
// - get: --version, -v (int) for specific version retrieval
// - delete: --versions, -v (string) for comma-separated version list
// - undelete: --versions, -v (string) for comma-separated version list
//
// Example usage:
//
// source := workloadapi.NewX509Source(...)
// Initialize(source)
func Initialize(source *workloadapi.X509Source, SPIFFEID string) {
rootCmd.AddCommand(policy.NewPolicyCommand(source, SPIFFEID))
rootCmd.AddCommand(secret.NewSecretCommand(source, SPIFFEID))
rootCmd.AddCommand(operator.NewOperatorCommand(source, SPIFFEID))
}
// Execute runs the root command and handles any errors that occur.
// If an error occurs during execution, it prints the error and exits
// with status code 1.
func Execute() {
var cmdErr error
if cmdErr = rootCmd.Execute(); cmdErr == nil {
return
}
if _, err := fmt.Fprintf(os.Stderr, "%v\n", cmdErr); err != nil {
fmt.Println("failed to write to stderr: ", 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"
)
// NewOperatorCommand 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
// - spiffeId: The SPIFFE ID associated with the operator
//
// Returns:
// - *cobra.Command: A configured cobra command for operator management
func NewOperatorCommand(
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/log"
"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 *workloadapi.X509Source: The X.509 source for SPIFFE
// authentication.
// - spiffeId string: 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) {
fmt.Println("")
fmt.Println(" You need to have a `recover` role to use this command.")
fmt.Println(
" Please run `./hack/bare-metal/entry/spire-server-entry-recover-register.sh`")
fmt.Println(" with necessary privileges to assign this role.")
fmt.Println("")
log.FatalLn("Aborting.")
}
trust.AuthenticateForPilotRecover(SPIFFEID)
api := spike.NewWithSource(source)
shards, err := api.Recover()
// Security: clean the shards when we no longer need them.
defer func() {
for _, shard := range shards {
mem.ClearRawBytes(shard)
}
}()
if err != nil {
log.FatalLn(err.Error())
}
if shards == nil {
fmt.Println("")
fmt.Println(" No shards found.")
fmt.Println(" Cannot save recovery shards.")
fmt.Println(" Please try again later.")
fmt.Println(" If the problem persists, check SPIKE logs.")
fmt.Println("")
return
}
for _, shard := range shards {
emptyShard := true
for _, v := range shard {
if v != 0 {
emptyShard = false
break
}
}
if emptyShard {
fmt.Println("")
fmt.Println(" Empty shard found.")
fmt.Println(" Cannot save recovery shards.")
fmt.Println(" Please try again later.")
fmt.Println(" If the problem persists, check SPIKE logs.")
}
}
// Creates the folder if it does not exist.
recoverDir := config.PilotRecoveryFolder()
// Clean the path to normalize it
cleanPath, err := filepath.Abs(filepath.Clean(recoverDir))
if err != nil {
fmt.Println("")
fmt.Println(" Error resolving recovery directory path.")
fmt.Println(" " + err.Error())
fmt.Println("")
log.FatalLn("Aborting.")
}
// Verify the path exists and is a directory
fileInfo, err := os.Stat(cleanPath)
if err != nil || !fileInfo.IsDir() {
fmt.Println("")
fmt.Println(" Invalid recovery directory path.")
fmt.Println(" Path does not exist or is not a directory.")
fmt.Println("")
log.FatalLn("Aborting.")
}
// Ensure the cleaned path doesn't contain suspicious components
// This helps catch any attempts at path traversal that survived cleaning
if strings.Contains(cleanPath, "..") ||
strings.Contains(cleanPath, "./") ||
strings.Contains(cleanPath, "//") {
fmt.Println("")
fmt.Println(" Invalid recovery directory path.")
fmt.Println(" Path contains suspicious components.")
fmt.Println("")
log.FatalLn("Aborting.")
}
// Ensure the recover directory is clean by
// deleting any existing recovery files.
// We are NOT warning the user about this operation because
// the admin ought to have securely backed up the shards and
// deleted them from the recover directory anyway.
if _, err := os.Stat(recoverDir); err == nil {
files, err := os.ReadDir(recoverDir)
if err != nil {
fmt.Printf("Failed to read recover directory %s: %s\n",
recoverDir, err.Error())
log.FatalLn(err.Error())
}
for _, file := range files {
if file.Name() != "" && filepath.Ext(file.Name()) == ".txt" &&
strings.HasPrefix(file.Name(), "spike.recovery") {
filePath := filepath.Join(recoverDir, file.Name())
err := os.Remove(filePath)
if err != nil {
fmt.Printf("Failed to delete old recovery file %s: %s\n",
filePath, err.Error())
}
}
}
}
// 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.
err := os.WriteFile(filePath, []byte(out), 0600)
// Security: Hint gc to reclaim memory.
encodedShard = "" // nolint:ineffassign
out = "" // nolint:ineffassign
runtime.GC()
if err != nil {
fmt.Printf("Failed to save shard %d: %s\n", i, err.Error())
}
}
fmt.Println("")
fmt.Println(" SPIKE Recovery shards saved to the recovery directory:")
fmt.Println(" " + recoverDir)
fmt.Println("")
fmt.Println(" Please make sure that:")
fmt.Println(" 1. You encrypt these shards and keep them safe.")
fmt.Println(" 2. Securely erase the shards from the")
fmt.Println(" recovery directory after you encrypt them")
fmt.Println(" and save them to a safe location.")
fmt.Println("")
fmt.Println(
" If you lose these shards, you will not be able to recover")
fmt.Println(
" SPIKE Nexus in the unlikely event of a total system crash.")
fmt.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"
"fmt"
"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/log"
"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 *workloadapi.X509Source: The X.509 source for SPIFFE
// authentication.
// - spiffeId string: 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 the SPIKE API 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) {
fmt.Println("")
fmt.Println(
" You need to have a `restore` role to use this command.")
fmt.Println(
" Please run " +
"`./hack/bare-metal/entry/spire-server-entry-restore-register.sh`")
fmt.Println(" with necessary privileges to assign this role.")
fmt.Println("")
os.Exit(1)
}
trust.AuthenticateForPilotRestore(SPIFFEID)
fmt.Println("(your input will be hidden as you paste/type it)")
fmt.Print("Enter recovery shard: ")
shard, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
_, e := fmt.Fprintf(os.Stderr, "Error reading shard: %v\n", err)
if e != nil {
return
}
os.Exit(1)
}
api := spike.NewWithSource(source)
var shardToRestore [crypto.AES256KeySize]byte
// shard is in `spike:$id:$base64` format
shardParts := strings.SplitN(string(shard), ":", 3)
if len(shardParts) != 3 {
fmt.Println("")
fmt.Println(
"Invalid shard format. Expected format: `spike:$id:$secret`.",
)
os.Exit(1)
}
index := shardParts[1]
hexData := shardParts[2]
// 32 bytes encoded in hex should be 64 characters
if len(hexData) != 64 {
fmt.Println("")
fmt.Println(
"Invalid hex shard length:", len(hexData),
"(expected 64 characters).",
"Did you miss some characters when pasting?",
)
os.Exit(1)
}
decodedShard, err := 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 err != nil {
fmt.Println("")
fmt.Println("Failed to decode recovery shard: ", err.Error())
os.Exit(1)
}
if len(decodedShard) != crypto.AES256KeySize {
// Security: reset decodedShard immediately after use.
mem.ClearBytes(decodedShard)
fmt.Println("")
fmt.Println("Invalid recovery shard length: ", len(decodedShard))
os.Exit(1)
}
for i := 0; i < crypto.AES256KeySize; i++ {
shardToRestore[i] = decodedShard[i]
}
// Security: reset decodedShard immediately after use.
mem.ClearBytes(decodedShard)
ix, err := strconv.Atoi(index)
if err != nil {
fmt.Println("")
fmt.Println("Invalid shard index: ", err.Error())
os.Exit(1)
}
status, err := api.Restore(ix, &shardToRestore)
// Security: reset shardToRestore immediately after recovery.
mem.ClearRawBytes(&shardToRestore)
if err != nil {
log.FatalLn(err.Error())
}
if status == nil {
fmt.Println("")
fmt.Println("Didn't get any status while trying to restore SPIKE.")
os.Exit(1)
}
if status.Restored {
fmt.Println("")
fmt.Println(" SPIKE is now restored and ready to use.")
fmt.Println(
" Please run " +
"`./hack/bare-metal/entry/spire-server-entry-su-register.sh`")
fmt.Println(
" with necessary privileges to start using SPIKE as a superuser.")
fmt.Println("")
} else {
fmt.Println("")
fmt.Println(" Shards collected: ", status.ShardsCollected)
fmt.Println(" Shards remaining: ", status.ShardsRemaining)
fmt.Println(
" Please run `spike operator restore` " +
"again to provide the remaining shards.")
fmt.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 (
"fmt"
"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/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
// - 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
// spiffeid: ^spiffe://example\.org/web-service/.*$
// path: ^secrets/web/database$
// permissions:
// - read
// - write
//
// The command will:
// 1. Validate that all required parameters are provided (either via
// flags or file)
// 2. Normalize the path pattern (remove trailing slashes)
// 3. Check if the system is initialized
// 4. Validate permissions and convert to the expected format
// 5. 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(cmd *cobra.Command, args []string) {
var policy data.PolicySpec
var err error
// Determine if we're using file-based or flag-based input
if filePath != "" {
// Read policy from the YAML file
policy, err = readPolicyFromFile(filePath)
if err != nil {
fmt.Printf("Error reading policy file: %v\n", err)
return
}
} else {
// Use flag-based input
policy, err = getPolicyFromFlags(name, SPIFFEIDPattern,
pathPattern, permsStr)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
}
// 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)
}
}
trust.AuthenticateForPilot(SPIFFEID)
api := spike.NewWithSource(source)
// Validate permissions
permissions, err := validatePermissions(ps)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Apply policy using upsert semantics
err = api.CreatePolicy(policy.Name, policy.SpiffeIDPattern,
policy.PathPattern, permissions)
if handleAPIError(err) {
return
}
fmt.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 (
"fmt"
"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/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
// - 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(cmd *cobra.Command, args []string) {
// 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 {
fmt.Println("Error: all flags are required")
for _, flag := range missingFlags {
fmt.Printf(" --%s is missing\n", flag)
}
return
}
trust.AuthenticateForPilot(SPIFFEID)
api := spike.NewWithSource(source)
// Validate permissions
permissions, err := validatePermissions(permsStr)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Check if a policy with this name already exists
exists, err := checkPolicyNameExists(api, name)
if handleAPIError(err) {
return
}
if exists {
fmt.Printf("Error: A policy with name '%s' already exists\n", name)
return
}
// Create policy
err = api.CreatePolicy(name, SPIFFEIDPattern, pathPattern, permissions)
if handleAPIError(err) {
return
}
fmt.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"
"fmt"
"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/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
// - 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(cmd *cobra.Command, args []string) {
trust.AuthenticateForPilot(SPIFFEID)
api := spike.NewWithSource(source)
policyID, err := sendGetPolicyIDRequest(cmd, args, api)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
// Confirm deletion
fmt.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" {
fmt.Println("Operation canceled")
return
}
err = api.DeletePolicy(policyID)
if handleAPIError(err) {
return
}
fmt.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 (
"fmt"
"strings"
"github.com/spiffe/spike/app/spike/internal/stdout"
)
// handleAPIError processes API errors and prints appropriate messages.
// It helps standardize error handling across policy commands.
//
// Parameters:
// - err: The error returned from an API call
//
// Returns:
// - bool: true if an error was handled, false if no error
//
// Usage example:
//
// policies, err := api.ListPolicies()
// if handleAPIError(err) {
// return
// }
func handleAPIError(err error) bool {
if err == nil {
return false
}
if err.Error() == "not ready" {
stdout.PrintNotReady()
return true
}
if strings.Contains(err.Error(), "unexpected end of JSON") ||
strings.Contains(err.Error(), "parsing") {
fmt.Println("Error: Failed to parse API response. " +
"The server may be unavailable or returned an invalid response.")
fmt.Printf("Technical details: %v\n", err)
return true
}
fmt.Printf("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 policy
import (
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
spike "github.com/spiffe/spike-sdk-go/api"
)
// 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
// - error: An error if the policy is not found or there's an API issue
func findPolicyByName(api *spike.API, name string) (string, error) {
policies, err := api.ListPolicies("", "")
if err != nil {
return "", err
}
if policies != nil {
for _, policy := range *policies {
if policy.Name == name {
return policy.ID, nil
}
}
}
return "", fmt.Errorf("no policy found with name '%s'", name)
}
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
// - error: 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, error) {
var policyID string
name, _ := cmd.Flags().GetString("name")
if len(args) > 0 {
policyID = args[0]
if !validUUID(policyID) {
return "", fmt.Errorf("invalid policy ID '%s'", policyID)
}
} else if name != "" {
id, err := findPolicyByName(api, name)
if err != nil {
return "", err
}
policyID = id
} else {
return "", fmt.Errorf(
"either policy ID as argument or --name flag is required",
)
}
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.CreatedBy != "" {
result.WriteString(fmt.Sprintf("Created By: %s\n",
policy.CreatedBy))
}
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.CreatedBy != "" {
result.WriteString(fmt.Sprintf("Created By: %s\n", policy.CreatedBy))
}
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 (
"fmt"
"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/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
// - 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(cmd *cobra.Command, args []string) {
trust.AuthenticateForPilot(SPIFFEID)
api := spike.NewWithSource(source)
// If the first argument is provided without the `--name` flag, it could
// be misinterpreted as trying to use policy name directly
if len(args) > 0 && !cmd.Flags().Changed("name") {
fmt.Println("Note: To look up a policy by name, use --name flag:")
fmt.Printf(" spike policy get --name=%s\n\n", args[0])
fmt.Printf("Attempting to use '%s' as policy ID...\n", args[0])
}
policyID, err := sendGetPolicyIDRequest(cmd, args, api)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
policy, err := api.GetPolicy(policyID)
if handleAPIError(err) {
return
}
if policy == nil {
fmt.Println("Error: Got empty response from server")
return
}
output := formatPolicy(cmd, policy)
fmt.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 (
"fmt"
"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/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
// - 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(cmd *cobra.Command, args []string) {
trust.AuthenticateForPilot(SPIFFEID)
api := spike.NewWithSource(source)
policies, err := api.ListPolicies(SPIFFEIDPattern, pathPattern)
if handleAPIError(err) {
return
}
output := formatPoliciesOutput(cmd, policies)
fmt.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"
)
// NewPolicyCommand 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
// - 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 NewPolicyCommand(
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 (
"fmt"
"os"
"strings"
"github.com/spiffe/spike-sdk-go/api/entity/data"
"gopkg.in/yaml.v3"
)
// readPolicyFromFile reads a policy configuration from a YAML file
func readPolicyFromFile(filePath string) (data.PolicySpec, error) {
var policy data.PolicySpec
// Check if the file exists:
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return policy, fmt.Errorf("file %s does not exist", filePath)
}
// Read file content
data, err := os.ReadFile(filePath)
if err != nil {
return policy, fmt.Errorf("failed to read file %s: %v", filePath, err)
}
// Parse YAML
err = yaml.Unmarshal(data, &policy)
if err != nil {
return policy, fmt.Errorf("failed to parse YAML file %s: %v", filePath, err)
}
// Validate required fields
if policy.Name == "" {
return policy, fmt.Errorf("policy name is required in YAML file")
}
if policy.SpiffeIDPattern == "" {
return policy, fmt.Errorf("spiffeidPattern is required in YAML file")
}
if policy.PathPattern == "" {
return policy, fmt.Errorf("pathPattern is required in YAML file")
}
if len(policy.Permissions) == 0 {
return policy, fmt.Errorf("permissions are required in YAML file")
}
return policy, nil
}
// getPolicyFromFlags extracts policy configuration from command line flags
func getPolicyFromFlags(name, SPIFFEIDPattern, pathPattern, permsStr string) (data.PolicySpec, error) {
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
}
return policy, fmt.Errorf("required flags are missing: %s (or use --file to read from YAML)", flagList)
}
// 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 (
"fmt"
"strings"
spike "github.com/spiffe/spike-sdk-go/api"
"github.com/spiffe/spike-sdk-go/api/entity/data"
)
// validatePermissions validates policy permissions from a comma-separated
// string and returns a slice of PolicyPermission values. Only "read", "write",
// "list", and "super" are valid permissions. It returns an error if any
// permission is invalid or if the string contains no valid permissions.
//
// Parameters:
// - permsStr: Comma-separated string of permissions (e.g., "read,write,list")
//
// Returns:
// - []data.PolicyPermission: Validated policy permissions
// - error: An error if any permission is invalid
func validatePermissions(permsStr string) ([]data.PolicyPermission, error) {
validPerms := map[string]bool{
"read": true,
"write": true,
"list": true,
"super": true,
}
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 _, ok := validPerms[perm]; !ok {
validPermsList := "read, write, list, super"
return nil, fmt.Errorf(
"invalid permission '%s'. Valid permissions are: %s",
perm, validPermsList)
}
perms = append(perms, data.PolicyPermission(perm))
}
if len(perms) == 0 {
return nil,
fmt.Errorf("no valid permissions specified. " +
"Valid permissions are: read, write, list, super")
}
return perms, nil
}
// 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
// - error: An error if there's an issue with the API call
func checkPolicyNameExists(api *spike.API, name string) (bool, error) {
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 (
"fmt"
"regexp"
"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"
)
const validPath = `^[a-zA-Z0-9._\-/()?+*|[\]{}\\]+$`
// Helper function to validate secret paths
func validSecretPath(path string) bool {
r := regexp.MustCompile(validPath)
return r.MatchString(path)
}
// 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: X.509 source for workload API authentication
//
// 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)
api := spike.NewWithSource(source)
path := args[0]
versions, _ := cmd.Flags().GetString("versions")
if !validSecretPath(path) {
fmt.Printf("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 {
fmt.Printf("Error: invalid version number: %s\n", v)
return
}
if version < 0 {
fmt.Printf(
"Error: version numbers cannot be negative: %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 err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return
}
fmt.Printf("Error: %v\n", err)
return
}
fmt.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"
"fmt"
"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: X.509 source for workload API authentication
//
// 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 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),
RunE: func(cmd *cobra.Command, args []string) error {
trust.AuthenticateForPilot(SPIFFEID)
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) {
return fmt.Errorf("invalid format specified: %s", format)
}
if !validSecretPath(path) {
return fmt.Errorf("invalid secret path: %s", path)
}
secret, err := api.GetSecretVersion(path, version)
if err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return fmt.Errorf("server not ready")
}
return fmt.Errorf("failure reading secret: %v", err.Error())
}
if secret == nil {
return fmt.Errorf("secret not found")
}
if secret.Data == nil {
return fmt.Errorf("secret has no data")
}
d := secret.Data
found := false
if format == "plain" || format == "p" {
for k, v := range d {
if len(args) < 2 || args[1] == "" {
fmt.Printf("%s: %s\n", k, v)
found = true
} else if args[1] == k {
fmt.Printf("%s\n", v)
found = true
break
}
}
if !found {
return fmt.Errorf("key not found")
}
} else {
var b []byte
if len(args) < 2 || args[1] == "" {
if format == "yaml" || format == "y" {
b, err = yaml.Marshal(d)
} else {
b, err = json.MarshalIndent(d, "", " ")
}
found = true
} else {
for k, v := range d {
if args[1] == k {
if format == "yaml" || format == "y" {
b, err = yaml.Marshal(v)
} else {
b, err = json.Marshal(v)
}
found = true
break
}
}
}
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
if !found {
return fmt.Errorf("key not found")
}
fmt.Printf("%s\n", string(b))
}
return nil
},
}
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 (
"fmt"
"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"
)
const notFoundMessage = "No secrets found."
// 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: X.509 source for workload API authentication
//
// 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)
api := spike.NewWithSource(source)
keys, err := api.ListSecretKeys()
if err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return
}
fmt.Println("Error listing secret keys:", err)
return
}
if keys == nil {
fmt.Println(notFoundMessage)
return
}
if len(*keys) == 0 {
fmt.Println(notFoundMessage)
return
}
for _, key := range *keys {
fmt.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 (
"fmt"
"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: X.509 source for workload API authentication
//
// 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)
api := spike.NewWithSource(source)
path := args[0]
version, _ := cmd.Flags().GetInt("version")
secret, err := api.GetSecretMetadata(path, version)
if err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return
}
fmt.Println("Error reading secret:", err.Error())
return
}
if secret == nil {
fmt.Println("Secret not found.")
return
}
printSecretResponse(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"
)
// NewSecretCommand creates a new Cobra command for managing secrets.
func NewSecretCommand(
source *workloadapi.X509Source, SPIFFEID string,
) *cobra.Command {
// trust.Authenticate(SPIFFEID)
cmd := &cobra.Command{
Use: "secret",
Short: "Manage secrets",
}
// Add subcommands to the policy 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 (
"fmt"
"strings"
"time"
"github.com/spiffe/spike-sdk-go/api/entity/data"
)
// formatTime formats a time.Time object into a readable string.
// The format used is "2006-01-02 15:04:05 MST".
func formatTime(t time.Time) string {
return t.Format("2006-01-02 15:04:05 MST")
}
// printSecretResponse prints secret metadata
func printSecretResponse(response *data.SecretMetadata) {
printSeparator := func() {
fmt.Println(strings.Repeat("-", 50))
}
hasMetadata := response.Metadata != (data.SecretMetaDataContent{})
rmd := response.Metadata
if hasMetadata {
fmt.Println("\nMetadata:")
printSeparator()
fmt.Printf("Current Version : %d\n", rmd.CurrentVersion)
fmt.Printf("Oldest Version : %d\n", rmd.OldestVersion)
fmt.Printf("Created Time : %s\n", formatTime(rmd.CreatedTime))
fmt.Printf("Last Updated : %s\n", formatTime(rmd.UpdatedTime))
fmt.Printf("Max Versions : %d\n", rmd.MaxVersions)
printSeparator()
}
if len(response.Versions) > 0 {
fmt.Println("\nSecret Versions:")
printSeparator()
for version, versionData := range response.Versions {
fmt.Printf("Version %d:\n", version)
fmt.Printf(" Created: %s\n", formatTime(versionData.CreatedTime))
if versionData.DeletedTime != nil {
fmt.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 (
"fmt"
"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: X.509 source for workload API authentication
//
// Returns:
// - *cobra.Command: Configured put command
//
// Arguments:
// 1. path: Location where the secret will be stored
// 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 will:
// 1. Verify SPIKE initialization status via admin token
// 2. Parse all key-value pairs from arguments
// 3. Store the collected key-value pairs at the specified path
//
// Error cases:
// - SPIKE not initialized: Prompts user to run 'spike init'
// - Invalid key-value format: Reports the malformed pair
// - Network/storage errors: Displays error message
//
// Note: Current admin token verification will be replaced with
// temporary token authentication in future versions
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)
api := spike.NewWithSource(source)
path := args[0]
if !validSecretPath(path) {
fmt.Printf("Error: invalid secret path: %s\n", path)
return
}
kvPairs := args[1:]
values := make(map[string]string)
for _, kv := range kvPairs {
if !strings.Contains(kv, "=") {
fmt.Printf("Error: invalid key-value pair format: %s\n", kv)
continue
}
kvs := strings.Split(kv, "=")
values[kvs[0]] = kvs[1]
}
if len(values) == 0 {
fmt.Println("OK")
return
}
err := api.PutSecret(path, values)
if err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return
}
fmt.Printf("Error: %v\n", err)
return
}
fmt.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 (
"fmt"
"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 trust to ensure:
// - Exactly one path argument is provided
// - Version numbers are valid non-negative integers
// - Version strings are properly formatted
//
// Note: Command currently provides feedback about intended operations
// but actual restoration functionality is pending implementation
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)
api := spike.NewWithSource(source)
path := args[0]
if !validSecretPath(path) {
fmt.Printf("Error: invalid secret path: %s\n", path)
return
}
versions, _ := cmd.Flags().GetString("versions")
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 {
fmt.Printf("Error: invalid version number: %s\n", v)
return
}
if version < 0 {
fmt.Printf(
"Error: version numbers cannot be negative: %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.UndeleteSecret(path, vv)
if err != nil {
if err.Error() == "not ready" {
stdout.PrintNotReady()
return
}
fmt.Printf("Error: %v\n", err)
return
}
fmt.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 env
import (
"os"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
)
// ShowMemoryWarning returns whether to display a warning when the system
// cannot lock memory based on the SPIKE_PILOT_SHOW_MEMORY_WARNING environment
// variable.
//
// The function reads the SPIKE_PILOT_SHOW_MEMORY_WARNING environment variable
// and returns:
// - false if the variable is not set (default behavior)
// - true if the variable is set to "true" (case-insensitive)
// - false for any other value
//
// The environment variable value is trimmed of whitespace and converted to
// lowercase before comparison.
//
// This warning is typically shown when memory locking fails, which could
// impact security-sensitive operations that require pages to remain in RAM.
func ShowMemoryWarning() bool {
s := os.Getenv(env.PilotShowMemoryWarning)
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return false
}
return s == "true"
}
// \\ 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"
import "os"
// PrintNotReady prints a message indicating that SPIKE is not initialized
// and provides instructions for troubleshooting and recovery.
// The message includes suggestions to wait, check logs, and information about
// manual bootstrapping if the initialization problem persists.
func PrintNotReady() {
if _, err := fmt.Fprintln(os.Stderr, `!
! 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.
!`); 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 (
"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) {
log.Log().Error(
fName,
"message",
"AuthenticateForPilot: You need a 'super user' SPIFFE ID to use this command.",
)
log.FatalLn(
fName,
"message",
"AuthenticateForPilot: You are not authorized to use this command (%s).\n",
SPIFFEID,
)
}
}
// 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) {
log.Log().Error(
fName,
"message",
"AuthenticateForPilotRecover: You need a 'recover' "+
"SPIFFE ID to use this command.",
)
log.FatalLn(
fName,
"message",
"AuthenticateForPilotRecover: You are not authorized to use this command (%s).\n",
SPIFFEID,
)
}
}
// 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) {
log.Log().Error(
fName,
"message",
"AuthenticateForPilotRestore: You need a 'restore' "+
"SPIFFE ID to use this command.",
)
log.FatalLn(
fName,
"message",
"AuthenticateForPilotRecover: You are not authorized to use this command (%s).\n",
SPIFFEID,
)
}
}
// \\ 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"
"github.com/google/goexpect"
"log"
"math/big"
"regexp"
"time"
)
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 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"
"os"
"path/filepath"
"strings"
"github.com/spiffe/spike-sdk-go/config/env"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike/app"
)
var NexusVersion = app.Version
var PilotVersion = app.Version
var KeeperVersion = app.Version
var BootstrapVersion = app.Version
// restrictedPaths contains system directories that should not be used
// for data storage for security and operational reasons.
var restrictedPaths = []string{
"/", "/etc", "/sys", "/proc", "/dev", "/bin", "/sbin",
"/usr", "/lib", "/lib64", "/boot", "/root",
}
// 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.
func validateDataDirectory(dir string) error {
if dir == "" {
return fmt.Errorf("directory path cannot be empty")
}
// Resolve to an absolute path
absPath, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("failed to resolve absolute path: %w", err)
}
// Check for restricted paths
for _, restricted := range restrictedPaths {
if absPath == restricted || strings.HasPrefix(absPath, restricted+"/") {
return fmt.Errorf(
"path %s is restricted for security reasons", absPath,
)
}
}
// Check if using /tmp without user isolation
if strings.HasPrefix(absPath, "/tmp/") && !strings.Contains(absPath, os.Getenv("USER")) {
log.Log().Warn("validateDataDirectory",
"message", "Using /tmp without user isolation is not recommended",
"path", absPath,
)
}
// Check if the directory exists
info, err := os.Stat(absPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("failed to check directory: %w", err)
}
// Directory doesn't exist, check if the parent exists, and we can create it
parentDir := filepath.Dir(absPath)
if _, err := os.Stat(parentDir); err != nil {
return fmt.Errorf(
"parent directory %s does not exist: %w", parentDir, err,
)
}
} else {
// Directory exists, check if it's actually a directory
if !info.IsDir() {
return fmt.Errorf("%s exists but is not a directory", absPath)
}
}
return nil
}
// 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.
func NexusDataFolder() string {
const fName = "NexusDataFolder"
// Check for custom data directory from the environment
if customDir := os.Getenv("SPIKE_NEXUS_DATA_DIR"); customDir != "" {
if err := validateDataDirectory(customDir); err == nil {
// Ensure the directory exists with proper permissions
dataPath := filepath.Join(customDir, "data")
if err := os.MkdirAll(dataPath, 0700); err != nil {
log.Log().Warn(fName,
"message", "Failed to create custom data directory",
"dir", dataPath,
"err", err.Error(),
)
} else {
return dataPath
}
} else {
log.Log().Warn(fName,
"message", "Invalid custom data directory, using default",
"dir", customDir,
"err", err.Error(),
)
}
}
// Fall back to home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fall back to temp with user isolation
user := os.Getenv("USER")
if user == "" {
user = "spike"
}
tempDir := fmt.Sprintf("/tmp/.spike-%s", user)
dataPath := filepath.Join(tempDir, "data")
err = os.MkdirAll(dataPath, 0700)
if err != nil {
panic(err)
}
return dataPath
}
spikeDir := filepath.Join(homeDir, ".spike")
dataPath := filepath.Join(spikeDir, "data")
// Create the directory if it doesn't exist
// 0700 because we want to restrict access to the directory
// but allow the user to create db files in it.
err = os.MkdirAll(dataPath, 0700)
if err != nil {
panic(err)
}
return dataPath
}
// 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.
func PilotRecoveryFolder() string {
const fName = "PilotRecoveryFolder"
// Check for custom recovery directory from environment
if customDir := os.Getenv(env.PilotRecoveryDir); customDir != "" {
if err := validateDataDirectory(customDir); err == nil {
// Ensure the directory exists with proper permissions
recoverPath := filepath.Join(customDir, "recover")
if err := os.MkdirAll(recoverPath, 0700); err != nil {
log.Log().Warn(fName,
"message", "Failed to create custom recovery directory",
"dir", recoverPath,
"err", err.Error(),
)
} else {
return recoverPath
}
} else {
log.Log().Warn(fName,
"message", "Invalid custom recovery directory, using default",
"dir", customDir,
"err", err.Error(),
)
}
}
// Fall back to home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fall back to temp with user isolation
user := os.Getenv("USER")
if user == "" {
user = "spike"
}
tempDir := fmt.Sprintf("/tmp/.spike-%s", user)
recoverPath := filepath.Join(tempDir, "recover")
err = os.MkdirAll(recoverPath, 0700)
if err != nil {
panic(err)
}
return recoverPath
}
spikeDir := filepath.Join(homeDir, ".spike")
recoverPath := filepath.Join(spikeDir, "recover")
// Create the directory if it doesn't exist
// 0700 because we want to restrict access to the directory
// but allow the user to create recovery files in it.
err = os.MkdirAll(recoverPath, 0700)
if err != nil {
panic(err)
}
return recoverPath
}
// \\ 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.Log().Info("route.factory", "path", p, "action", a, "method", m)
// We only accept POST requests -- See ADR-0012.
if m != http.MethodPost {
return Fallback
}
return switchyard(a, p)
}
package net
import (
"net/http"
"time"
"github.com/spiffe/spike-sdk-go/crypto"
"github.com/spiffe/spike/internal/journal"
)
// Handler is a function type that processes HTTP requests with audit
// logging support.
type Handler func(http.ResponseWriter, *http.Request, *journal.AuditEntry) error
// 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()
entry := journal.AuditEntry{
TrailID: crypto.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 (
"bytes"
"errors"
"io"
"net/http"
apiErr "github.com/spiffe/spike-sdk-go/api/errors"
"github.com/spiffe/spike-sdk-go/log"
)
func body(r *http.Response) (bod []byte, err error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
return body, err
}
// 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.
// - error: An error if any of the following occur:
// - Connection failure during POST request
// - Non-200 status code in response
// - Failure to read response body
// - Failure to close response body
//
// The function ensures proper cleanup by always attempting to close the
// response body, even if an error occurs during reading. Any error from closing
// the body is joined with any existing error using errors.Join.
//
// 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, error) {
log.Log().Info("post", "path", path)
// Create the request while preserving the mTLS client
req, err := http.NewRequest("POST", path, bytes.NewBuffer(mr))
if err != nil {
return nil, errors.Join(
errors.New("post: Failed to create request"),
err,
)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
// Use the existing mTLS client to make the request
r, err := client.Do(req)
if err != nil {
return []byte{}, errors.Join(
apiErr.ErrPeerConnection,
err,
)
}
if r.StatusCode != http.StatusOK {
if r.StatusCode == http.StatusNotFound {
return []byte{}, apiErr.ErrNotFound
}
if r.StatusCode == http.StatusUnauthorized {
return []byte{}, apiErr.ErrUnauthorized
}
return []byte{}, apiErr.ErrPeerConnection
}
b, err := body(r)
if err != nil {
return []byte{}, errors.Join(
apiErr.ErrReadingResponseBody,
err,
)
}
defer func(b io.ReadCloser) {
if b == nil {
return
}
err = errors.Join(err, b.Close())
}(r.Body)
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"
"errors"
"io"
"net/http"
"github.com/spiffe/spike-sdk-go/log"
"github.com/spiffe/spike-sdk-go/net"
)
// ReadRequestBody reads the entire request body from an HTTP request.
// It returns the body as a byte slice if successful. If there is an error
// reading the body or if the body is nil, it writes a 400 Bad Request status
// to the response writer and returns an empty byte slice. Any errors
// encountered are logged.
func ReadRequestBody(w http.ResponseWriter, r *http.Request) []byte {
body, err := net.RequestBody(r)
if err != nil {
log.Log().Info("readRequestBody",
"message", "Problem reading request body",
"err", err.Error())
w.WriteHeader(http.StatusBadRequest)
_, err := io.WriteString(w, "")
if err != nil {
log.Log().Info("readRequestBody",
"message", "Problem writing response",
"err", err.Error())
}
return []byte{}
}
if body == nil {
log.Log().Info("readRequestBody", "message", "No request body.")
w.WriteHeader(http.StatusBadRequest)
_, err := io.WriteString(w, "")
if err != nil {
log.Log().Info("readRequestBody",
"message", "Problem writing response",
"err", err.Error())
}
return []byte{}
}
return body
}
// HandleRequestError handles HTTP request errors by writing a 400 Bad Request
// status to the response writer. If err is nil, it returns nil. Otherwise, it
// writes the error status and returns a joined error containing both the
// original error and any error encountered while writing the response.
func HandleRequestError(w http.ResponseWriter, err error) error {
if err == nil {
return nil
}
w.WriteHeader(http.StatusBadRequest)
_, writeErr := io.WriteString(w, "")
return errors.Join(err, writeErr)
}
// HandleRequest 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: []byte - The raw JSON request body to unmarshal
// - w: http.ResponseWriter - The response writer for error handling
// - errorResponse: Res - A response object to send if unmarshaling fails
//
// Returns:
// - *Req - A pointer to the unmarshaled request struct, or nil if
// unmarshaling failed
//
// 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 HandleRequest[Req any, Res any](
requestBody []byte,
w http.ResponseWriter,
errorResponse Res,
) *Req {
var request Req
if err := HandleRequestError(
w, json.Unmarshal(requestBody, &request),
); err != nil {
log.Log().Error("HandleRequest",
"message", "Problem unmarshalling request",
"err", err.Error())
responseBody := MarshalBody(errorResponse, w)
if responseBody == nil {
return nil
}
Respond(http.StatusBadRequest, responseBody, w)
return nil
}
return &request
}
// \\ SPIKE: Secure your secrets with SPIFFE. — https://spike.ist/
// \\\\\ Copyright 2024-present SPIKE contributors.
// \\\\\\\ SPDX-License-Identifier: Apache-2.0
package net
import (
"encoding/json"
"errors"
"net/http"
"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/log"
"github.com/spiffe/spike/internal/journal"
)
// MarshalBody 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
func MarshalBody(res any, w http.ResponseWriter) []byte {
body, err := json.Marshal(res)
if err != nil {
log.Log().Error("marshalBody",
"message", "Problem generating response",
"err", err.Error())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write([]byte(`{"error":"internal server error"}`))
if err != nil {
log.Log().Error("marshalBody",
"message", "Problem writing response",
"err", err.Error())
return nil
}
return nil
}
return body
}
// 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) {
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 {
log.Log().Error("routeKeep",
"message", "Problem writing response",
"err", err.Error())
}
}
// 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
// MarshalBody to generate the response and handles any errors during response
// writing.
//
// Parameters:
// - w: http.ResponseWriter - The response writer
// - r: *http.Request - The incoming request
//
// The response always includes:
// - Status: 400 Bad Request
// - Content-Type: application/json
// - Body: JSON object with an error field
func Fallback(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
log.Log().Info("fallback",
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery)
audit.Action = journal.AuditFallback
body := MarshalBody(reqres.FallbackResponse{Err: data.ErrBadInput}, w)
if body == nil {
return errors.New("failed to marshal response body")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
if _, err := w.Write(body); err != nil {
log.Log().Error("routeFallback",
"message", "Problem writing response",
"err", err.Error())
return err
}
return nil
}
// NotReady handles requests when the system has not initialized its backing
// store with a root key by returning a 400 Bad Request.
//
// This function uses MarshalBody 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 containing ErrLowEntropy
//
// Returns:
// - error: Returns nil on success, or an error if response marshaling or
// writing fails
func NotReady(
w http.ResponseWriter, r *http.Request, audit *journal.AuditEntry,
) error {
log.Log().Info("not-ready",
"method", r.Method,
"path", r.URL.Path,
"query", r.URL.RawQuery)
audit.Action = journal.AuditBlocked
body := MarshalBody(reqres.FallbackResponse{Err: data.ErrNotReady}, w)
if body == nil {
return errors.New("failed to marshal response body")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
if _, err := w.Write(body); err != nil {
log.Log().Error("routeNotReady",
"message", "Problem writing response",
"err", err.Error())
return err
}
return nil
}