Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/design/keyless-secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
**Area:** `pkg/api/secrets` (encryption envelope, recipients, CLI `secrets` commands)
**Relates to:** [`docs/SECRETS-POLICY.md`](../../SECRETS-POLICY.md), [`docs/SECURITY.md`](../../SECURITY.md)

> **v1 implementation note (supersedes parts of this RFC):** the shipped "Minimal v1" is
> [`scoped-store-v1.md`](./scoped-store-v1.md). It deliberately diverges from the RFC's
> tooling decision: it reuses **sc's own ciphers** (RSA-OAEP + X25519 sealed box), NOT SOPS
> (adding SOPS would be a large new dependency for no capability sc lacks), and recipients are
> **SSH keys**, not native age. Scope selection is **key-driven** (a job decrypts exactly the
> scopes its key is a recipient of), which replaces the RFC's `secretScope:` config field with
> a cryptographic clamp. The KMS-wrapped / OIDC-federated recipient in this RFC remains the
> **v2** target and slots in as an additional recipient type on the same file format.

## Summary

Today, decrypting an SC secret store in CI requires materializing the store's
Expand Down
323 changes: 323 additions & 0 deletions docs/design/keyless-secrets/scoped-store-v1.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions docs/docs/guides/secrets-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,75 @@ sc secrets allowed-keys --verbose
sc secrets allowed-keys --profile github --verbose
```

## Per-scope secrets (`secrets.<scope>.yaml`)

The whole-file store (`.sc/secrets.yaml`) is decrypted by a single master key
(`SIMPLE_CONTAINER_CONFIG`) — every CI context that needs *any* secret can read *all* of
them. **Per-scope secrets** let you carve out a named subset (a "scope") sealed to its own
recipient set, so a narrow, attacker-reachable context — typically a `pull_request`-triggered
scan job — can hold a key that decrypts only that scope.

Scoped values live in committed, encrypted files alongside a stack's config:

```
.sc/
scopes.yaml # scope -> recipient keys (governance; CODEOWNERS-gate this)
stacks/<stack>/
secrets.yaml # legacy whole-file store (unchanged)
secrets.pr.yaml # scope "pr": committed encrypted, structure diffable, values opaque
```

Recipients are ordinary **SSH public keys** (`ssh-ed25519` / `ssh-rsa`). Each value is sealed
once per recipient and bound to its `(stack, scope, key)`, so a ciphertext cannot be moved to
another stack, scope, or key. Old `sc` binaries never read these files (they fail closed on
the newer schema).

### Commands

```bash
# Declare a scope and its recipients (edit .sc/scopes.yaml behind a CODEOWNERS gate).
sc secrets scope allow --scope pr <ssh-pubkey> # add a recipient + reseal the scope's files
sc secrets scope disallow --scope pr <ssh-pubkey> # remove a recipient + reseal (then rotate values!)

# Manage values in a scope.
sc secrets scope set --scope pr -s <stack> KEY <value> # or omit value / pass '-' to read stdin
sc secrets scope get --scope pr -s <stack> KEY
sc secrets scope list --scope pr -s <stack>
sc secrets scope delete --scope pr -s <stack> KEY

# Guardrails.
sc secrets scope lint # CI gate: encryption shape, recipient drift, scope/stack binding, duplicate keys
sc secrets scope doctor # which scopes the current key can open
```

Commit only the encrypted `secrets.<scope>.yaml` and `scopes.yaml`; never the legacy
plaintext `stacks/*/secrets.yaml`.

### Using a scope key in CI

Give the job the scope's private key and it decrypts only that scope — no
`SIMPLE_CONTAINER_CONFIG` required. `get` (and deploy-time `${secret:}` resolution) look up
the decryption key in order: `--key-file`, then env `SC_KEY_<SCOPE>` (e.g. `SC_KEY_PR`) or the
generic `SC_SCOPE_KEY`, then the ambient `SIMPLE_CONTAINER_CONFIG`.

```yaml
# a pull_request scan job — holds ONLY the pr-scope key
env:
SC_KEY_PR: ${{ secrets.SC_KEY_PR }} # ssh-ed25519 private key, recipient of the "pr" scope only
steps:
- run: DD_KEY=$(sc secrets scope get --scope pr -s integrail defectdojo-api-key)
```

At deploy time, `${secret:KEY}` and `sc stack secret-get` transparently include every scope
the job's key can open, merged over the whole-file store (the legacy store wins on conflict;
`sc secrets scope lint` rejects a key that appears in two scopes or in both a scope and the
legacy store). A key that is **not** a recipient of a scope cannot decrypt it — the
`pull_request` clamp is cryptographic, not a config flag.

> **Rotation:** `disallow` re-encrypts current files but does NOT rewrite git history — that
> recipient can still read previously committed versions. Always rotate the scope's values
> after removing a recipient.

## Summary

Simple Container's secrets management provides a secure, Git-native way to handle sensitive data in your projects. Key benefits:
Expand All @@ -715,5 +784,6 @@ Simple Container's secrets management provides a secure, Git-native way to handl
- **Simple**: Easy-to-use commands for all secret operations
- **Collaborative**: Git-based workflow for team secret sharing
- **Integrated**: Seamless integration with Simple Container deployments
- **Scoped**: Per-scope keys so a narrow CI context decrypts only what it needs

Use the commands outlined in this guide to implement robust secrets management in your Simple Container projects.
109 changes: 109 additions & 0 deletions pkg/api/secrets/ciphers/aad_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT
// Copyright (c) Simple Container

package ciphers

import (
"encoding/base64"
"strings"
"testing"

. "github.com/onsi/gomega"
)

// TestAAD_NilRoundTrip_LegacyCompatible proves a nil-AAD seal (the whole-file
// store's path) still round-trips, for both RSA and ed25519 recipients.
func TestAAD_NilRoundTrip_LegacyCompatible(t *testing.T) {
RegisterTestingT(t)
rpriv, rpub, err := GenerateKeyPair(2048)
Expect(err).NotTo(HaveOccurred())
chunks, err := EncryptLargeStringWithAAD(rpub, "legacy-value", nil)
Expect(err).NotTo(HaveOccurred())
got, err := DecryptLargeStringWithAAD(rpriv, chunks, nil)
Expect(err).NotTo(HaveOccurred())
Expect(string(got)).To(Equal("legacy-value"))

epriv, epub, err := GenerateEd25519KeyPair()
Expect(err).NotTo(HaveOccurred())
echunks, err := EncryptLargeStringWithAAD(epub, "legacy-ed", nil)
Expect(err).NotTo(HaveOccurred())
egot, err := DecryptLargeStringWithEd25519AAD(epriv, echunks, nil)
Expect(err).NotTo(HaveOccurred())
Expect(string(egot)).To(Equal("legacy-ed"))
}

// TestAAD_MismatchFails is the core security invariant: a value sealed under a
// specific AAD must NOT decrypt under a different (or nil) AAD, for both schemes.
func TestAAD_MismatchFails(t *testing.T) {
RegisterTestingT(t)
ctx := []byte("sc-scope-v1\x00pr\x00k")
other := []byte("sc-scope-v1\x00prod\x00k")

// RSA (OAEP label)
rpriv, rpub, err := GenerateKeyPair(2048)
Expect(err).NotTo(HaveOccurred())
rchunks, err := EncryptLargeStringWithAAD(rpub, "v", ctx)
Expect(err).NotTo(HaveOccurred())
_, err = DecryptLargeStringWithAAD(rpriv, rchunks, nil)
Expect(err).To(HaveOccurred())
_, err = DecryptLargeStringWithAAD(rpriv, rchunks, other)
Expect(err).To(HaveOccurred())
ok, err := DecryptLargeStringWithAAD(rpriv, rchunks, ctx)
Expect(err).NotTo(HaveOccurred())
Expect(string(ok)).To(Equal("v"))

// ed25519 (AEAD associated data)
epriv, epub, err := GenerateEd25519KeyPair()
Expect(err).NotTo(HaveOccurred())
echunks, err := EncryptLargeStringWithAAD(epub, "v", ctx)
Expect(err).NotTo(HaveOccurred())
_, err = DecryptLargeStringWithEd25519AAD(epriv, echunks, nil)
Expect(err).To(HaveOccurred())
_, err = DecryptLargeStringWithEd25519AAD(epriv, echunks, other)
Expect(err).To(HaveOccurred())
eok, err := DecryptLargeStringWithEd25519AAD(epriv, echunks, ctx)
Expect(err).NotTo(HaveOccurred())
Expect(string(eok)).To(Equal("v"))
}

// TestRSAChunkFraming_ReorderTruncateSpliceFails covers P0-B: a multi-chunk RSA
// value bound with a non-nil AAD must reject reordered/dropped chunks.
func TestRSAChunkFraming_ReorderTruncateSpliceFails(t *testing.T) {
RegisterTestingT(t)
priv, pub, err := GenerateKeyPair(2048)
Expect(err).NotTo(HaveOccurred())
aad := []byte("sc-scope-v1\x00prod\x00conn")
long := strings.Repeat("A", 200) + strings.Repeat("B", 200) // >128B ⇒ multiple chunks
chunks, err := EncryptLargeStringWithAAD(pub, long, aad)
Expect(err).NotTo(HaveOccurred())
Expect(len(chunks)).To(BeNumerically(">=", 2), "value must span multiple RSA chunks")

// baseline round-trip works
got, err := DecryptLargeStringWithAAD(priv, chunks, aad)
Expect(err).NotTo(HaveOccurred())
Expect(string(got)).To(Equal(long))

// reorder → fail (index binding)
reordered := append([]string{}, chunks...)
reordered[0], reordered[1] = reordered[1], reordered[0]
_, err = DecryptLargeStringWithAAD(priv, reordered, aad)
Expect(err).To(HaveOccurred())

// truncate (drop last) → fail (count binding)
_, err = DecryptLargeStringWithAAD(priv, chunks[:len(chunks)-1], aad)
Expect(err).To(HaveOccurred())
}

// TestEd25519Downgrade_RejectedUnderAAD covers P0-A: a non-X25519 (legacy-shaped)
// blob must be refused when an AAD binding is expected, so a forged legacy blob
// cannot bypass the scope/key binding.
func TestEd25519Downgrade_RejectedUnderAAD(t *testing.T) {
RegisterTestingT(t)
priv, _, err := GenerateEd25519KeyPair()
Expect(err).NotTo(HaveOccurred())
// A blob without the scx25519 magic looks like the legacy format.
fakeLegacy := base64.StdEncoding.EncodeToString(make([]byte, 60))
_, err = DecryptLargeStringWithEd25519AAD(priv, []string{fakeLegacy}, []byte("sc-scope-v1\x00pr\x00k"))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("legacy"))
}
71 changes: 71 additions & 0 deletions pkg/api/secrets/ciphers/dek.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
// Copyright (c) Simple Container

package ciphers

import (
"crypto/rand"

"github.com/pkg/errors"
"golang.org/x/crypto/chacha20poly1305"
)

// DEKSize is the length of a data-encryption key for the envelope AEAD.
const DEKSize = chacha20poly1305.KeySize

// GenerateDEK returns a fresh random 32-byte data-encryption key.
func GenerateDEK() ([]byte, error) {
k := make([]byte, DEKSize)
if _, err := rand.Read(k); err != nil {
return nil, errors.Wrap(err, "failed to generate data key")
}
return k, nil
}

// SealAEAD encrypts plaintext under a 32-byte key with ChaCha20-Poly1305, binding
// aad, and returns nonce(12) || ciphertext+tag. This is the envelope value blob:
// the value is encrypted ONCE with a random DEK and the DEK is wrapped per
// recipient, so all recipients see the same value (no per-recipient divergence)
// and a single whole-value MAC prevents chunk splicing.
func SealAEAD(key, plaintext, aad []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, errors.Wrap(err, "failed to create AEAD")
}
nonce := make([]byte, chacha20poly1305.NonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, errors.Wrap(err, "failed to generate nonce")
}
ct := aead.Seal(nil, nonce, plaintext, aad)
out := make([]byte, 0, len(nonce)+len(ct))
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}

// OpenAEAD reverses SealAEAD; aad must match.
func OpenAEAD(key, blob, aad []byte) ([]byte, error) {
if len(blob) < chacha20poly1305.NonceSize+chacha20poly1305.Overhead {
return nil, errors.New("envelope ciphertext too short")
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, errors.Wrap(err, "failed to create AEAD")
}
nonce := blob[:chacha20poly1305.NonceSize]
ct := blob[chacha20poly1305.NonceSize:]
pt, err := aead.Open(nil, nonce, ct, aad)
if err != nil {
return nil, errors.Wrap(err, "failed to decrypt envelope value")
}
return pt, nil
}

// ValidEnvelopeBlob reports whether raw is a plausibly-shaped envelope value blob
// (nonce + at least the AEAD tag), for the offline lint gate.
func ValidEnvelopeBlob(raw []byte) error {
if len(raw) < chacha20poly1305.NonceSize+chacha20poly1305.Overhead {
return errors.Errorf("envelope value blob is %d bytes, below the %d-byte minimum", len(raw), chacha20poly1305.NonceSize+chacha20poly1305.Overhead)
}
return nil
}
Loading
Loading