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
21 changes: 18 additions & 3 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 Expand Up @@ -268,9 +277,15 @@ itself; supply-chain compromise of the CLI or CI actions (mitigated by signed re
## Migration phases

0. **Design + review** (this document).
1. **v2 envelope + version-aware fail-closed client**, released and rolled out before
any v2 write. Modernize the asymmetric recipient scheme as part of v2.
2. **`KeyProvider` + first KMS provider + OIDC acquisition**, behind a feature flag.
1. **Envelope + version-aware fail-closed client**, released and rolled out before
any scoped write. Asymmetric recipient scheme modernized (X25519 for ed25519).
**DONE** — shipped; see [`scoped-store-v1.md`](./scoped-store-v1.md).
2. **`KeyProvider` + first KMS provider (AWS) + OIDC acquisition.** **DONE (code)** — the
`awskms://` recipient kind is implemented in the scoped store: the per-value data key is
wrapped with `kms:Encrypt` and opened with `kms:Decrypt` under the ambient (OIDC) AWS
credential chain, bound by a KMS EncryptionContext. It is additive (no flag needed — a
scope simply lists a KMS recipient); the remaining work is the per-stack consumer rollout
in phases 3–7 below.
3. **Canary** on one low-risk staging stack. Gate: `sc deploy` obtains cloud
credentials from the federated environment, not the store; measured KMS latency;
verified rollback runbook.
Expand Down
365 changes: 365 additions & 0 deletions docs/design/keyless-secrets/scoped-store-v1.md

Large diffs are not rendered by default.

218 changes: 218 additions & 0 deletions docs/docs/guides/secrets-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,223 @@ 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
```

A recipient is either an **SSH public key** (`ssh-ed25519` / `ssh-rsa`) or an **AWS KMS key**
(`awskms://<key>?region=<region>`). Each value is encrypted once under a random data key bound
to its `(stack, scope, key)`; that data key is then wrapped for every recipient. A ciphertext
therefore cannot be moved to another stack, scope, or key, and every recipient decrypts the
same value. Old `sc` binaries never read these files (they fail closed on the newer schema).

- **SSH recipient** — the private key must be provided to decrypt (a file, a CI secret, or the
ambient config). Good for humans and for the interim CI key.
- **KMS recipient** — the data key is wrapped with `kms:Encrypt` and unwrapped with
`kms:Decrypt`, using the caller's **ambient AWS credentials**. In CI those come from an
**OIDC-federated role**, so *no private key is stored anywhere* — this is the recommended
end state for CI (see "KMS recipients" below).

### Commands

```bash
# Declare a scope and its recipients (edit .sc/scopes.yaml behind a CODEOWNERS gate).
# A recipient is an SSH public key OR an awskms:// URL.
sc secrets scope allow --scope pr <ssh-pubkey> # add an SSH recipient + reseal
sc secrets scope allow --scope pr 'awskms://alias/sc-pr?region=us-east-1' # add a KMS recipient + reseal
sc secrets scope disallow --scope pr <recipient> # 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.

### Runbook: a leaked scope key

`disallow` stops a removed recipient from reading *future* commits, but the values it could
already read must be treated as compromised — git history is immutable. To revoke a leaked
scope key (SSH or KMS):

```bash
# 1. Remove the leaked recipient from the scope (reseals current files to the rest).
sc secrets scope disallow --scope pr <leaked-ssh-pubkey | awskms://…>

# 2. ROTATE every value in the scope at its source, then re-set it — the old ciphertext in
# git history is still readable by the leaked key, so the values themselves must change.
sc secrets scope list --scope pr -s <stack> # enumerate what to rotate
# for each KEY: rotate upstream (new API token, etc.), then:
sc secrets scope set --scope pr -s <stack> KEY <new-value>

# 3. Commit the resealed scope file(s) + scopes.yaml.
git add .sc/scopes.yaml .sc/stacks/<stack>/secrets.*.yaml && git commit -S -s -m "rotate pr scope after key revocation"
```

Then, depending on the recipient kind:

- **SSH scope key (`SC_KEY_PR`)** — delete/replace the GitHub secret; the old private key can
no longer decrypt the rotated values (and is no longer a recipient going forward).
- **KMS recipient** — the "key" is the IAM permission to call `kms:Decrypt`, not a stored
secret. Revoke it by removing `kms:Decrypt` from the compromised role/principal (or, if the
KMS key itself is suspect, schedule key deletion / rotate to a new key and re-`allow` it).
Because decryption requires assuming the OIDC role, tightening the role's trust policy or
IAM immediately cuts off access — no secret to invalidate.

Prefer at least one **SSH break-glass recipient** on every scope so you can always reseal in
step 1 even if the KMS path is unavailable, and keep `sc secrets scope lint` a **required** CI
check so the collision/duplicate guarantees are enforced, not just advisory.

### KMS recipients (keyless CI, no stored key)

A stored `SC_KEY_PR` is a smaller master key, but it is still a long-lived private key sitting
in a CI secret. A **KMS recipient** removes it entirely: the scope's data keys are wrapped for
an AWS KMS key, and a CI job unwraps them with an **OIDC-federated IAM role** — so the job
proves its identity to AWS per-run and holds no secret key at all.

The recipient string is `sc`'s canonical KMS URL — the same form the Pulumi state secrets
provider uses:

```
awskms://<key-id | alias/<name> | arn:aws:kms:...>?region=<region>
```

**Add the KMS recipient** (needs `kms:Encrypt` on the key for the reseal). Keep at least one
SSH break-glass recipient so you can always reseal/recover if the KMS path is unavailable:

```bash
# scopes.yaml already has an SSH break-glass recipient; add the KMS key beside it.
# Prefer the key ARN over an alias (see "Secure-usage rules" — an alias can be repointed).
sc secrets scope allow --scope pr 'awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1'
sc secrets scope set --scope pr -s integrail defectdojo-api-key 'dd-…' # sealed to BOTH recipients
```

**The resulting scope file** carries one opaque KMS wrap per value keyed by the KMS URL,
alongside the SSH wrap — no plaintext, fully diffable structure:

```yaml
schemaVersion: 1
stack: integrail
scope: pr
recipients:
- awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1
- ssh-ed25519 AAAA…break-glass
values:
defectdojo-api-key:
ciphertext: <AEAD value under a random data key>
wraps:
"awskms://arn:aws:kms:us-east-1:123456789012:key/abcd-1234?region=us-east-1": [<data key wrapped by KMS>]
"SHA256:…break-glass": [<data key sealed to the SSH key>]
```

**Consume it in CI with OIDC — no `SC_KEY_PR`, no `SIMPLE_CONTAINER_CONFIG`:**

```yaml
# a pull_request scan job — federates to AWS, then decrypts only the "pr" scope
permissions:
id-token: write # required for OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::<account>:role/ci-oidc-pr-scan # kms:Decrypt on the pr key only
aws-region: us-east-1
- run: DD_KEY=$(sc secrets scope get --scope pr -s integrail defectdojo-api-key)
```

`get`, `doctor`, and deploy-time `${secret:}` resolution all try, in order: an explicit
`--key-file`, the `SC_KEY_<SCOPE>` / `SC_SCOPE_KEY` env keys, the ambient
`SIMPLE_CONTAINER_CONFIG`, and finally a KMS Decrypt for any `awskms://` recipient using the
ambient AWS credentials. A value with no KMS recipient never triggers an AWS call.

**Secure-usage rules for KMS recipients**

- **Prefer a key ARN as the recipient, not an alias.** `awskms://alias/…` is convenient, but an
alias only pins the key *by name* — an account admin who repoints the alias changes which key
the recipient resolves to. The **key ARN** (`awskms://arn:aws:kms:<region>:<acct>:key/<id>`)
is the durable security boundary; use it for anything sensitive and treat aliases as a
convenience for low-risk scopes. `sc` pins the `KeyId` on every decrypt, so a value wrapped
under a *different* key than the recipient names is rejected as a hard integrity error.
- **Scope the IAM role tightly — pin stack AND scope.** Allow `kms:Decrypt` on *only* the
scope's key, and constrain the encryption context. `sc` sets it to
`{sc:domain, sc:stack, sc:scope, sc:key}`, so condition on both `sc:stack` and `sc:scope`:

```json
"Condition": { "StringEquals": {
"kms:EncryptionContext:sc:scope": "pr",
"kms:EncryptionContext:sc:stack": "integrail"
}}
```

Conditioning on `sc:scope` alone lets one role decrypt *every* stack's `pr` values wrapped
under that key — fine if one key serves one trust level and stacks are mutually trusted, but
add `sc:stack` when stacks must not read each other. KMS enforces the context server-side, so
a value transplanted to another stack/scope/key fails to decrypt.
- **Never give the PR-scan role `kms:Decrypt` on a deploy/prod key.** The whole point is that
a compromised PR job cannot reach deploy-grade secrets. Use a separate key (and role) per
trust level; a deploy scope's key stays out of the PR role's policy.
- **The clamp is cryptographic.** A role that is not a recipient of `secrets.prod.yaml` (no
`kms:Decrypt` on its key) cannot open it, regardless of anything in a PR's config.
- **The stack/scope/key are NOT secret.** They ride as the KMS EncryptionContext and are logged
in CloudTrail (desirable for audit) — do not put a secret-ish string in a stack, scope, or
key *name* expecting it to stay private.
- **How failures are classified.** A KMS `InvalidCiphertext` (mangled blob or broken
stack/scope/key binding) or `IncorrectKey` (the wrap was made under a different key than the
recipient names — a transplant) is a hard **integrity** error that fails the deploy as tamper.
`AccessDenied`, a disabled key, or no ambient credentials is a least-privilege **skip** (the
scope is simply not yours; a *missing* required `${secret:}` then fails closed downstream). A
transient KMS fault (throttle after SDK retries, `KMSInternal`, timeout) fails the deploy as
**unavailable** (retry) — never silently and never mislabeled as tamper.
- **Rotation still applies.** Removing a KMS recipient re-encrypts current files but does not
rewrite history — rotate the values, and additionally rotate/disable the KMS key material if
the key itself is the concern.

## 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 +932,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 recipients (SSH keys or KMS-via-OIDC) so a narrow CI context decrypts only what it needs — with the KMS path storing no key at all

Use the commands outlined in this guide to implement robust secrets management in your Simple Container projects.
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.56.4
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.75.2
github.com/aws/aws-sdk-go-v2/service/kms v1.50.3
github.com/aws/aws-secretsmanager-caching-go/v2 v2.2.0
github.com/aws/smithy-go v1.27.1
github.com/cloudflare/cloudflare-go v0.117.0
github.com/compose-spec/compose-go v1.20.2
github.com/containerd/platforms v0.2.1
Expand Down Expand Up @@ -129,14 +131,12 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bazelbuild/buildtools v0.0.0-20260211083412-859bfffeef82 // indirect
github.com/beorn7/perks v1.0.1 // indirect
Expand Down
Loading
Loading