Skip to content
294 changes: 294 additions & 0 deletions docs/design/keyless-secrets/README.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions docs/docs/guides/secrets-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ Each recipient public key gets its own encryption of the secret, using a scheme
- **`ssh-rsa` recipients** — RSA-OAEP (SHA-256).
- **`ssh-ed25519` recipients** — an ephemeral-static **X25519 ECDH** sealed box (HKDF-SHA256 + ChaCha20-Poly1305): the content key is derived from the ECDH shared secret, so only the holder of the ed25519 private key can decrypt.

### Store format & version compatibility

The encrypted store `.sc/secrets.yaml` carries an optional `schemaVersion` field
that identifies its schema. The current format is **schema version 0** — written
without an explicit `schemaVersion:` field, so existing stores are unchanged.

`sc` is **fail-closed** on this: a build refuses to read a store whose
`schemaVersion` is newer than it understands, rather than silently dropping fields
it cannot model and corrupting the store on the next write. The error looks like:

```
secrets file ".sc/secrets.yaml" is schema version N, but this sc build supports up
to schema version M; upgrade sc (refusing to read to avoid data loss)
```

This guarantee holds on **every** path — the local CLI and the GitHub Actions
deploy/reveal flows (a too-new store halts the run instead of falling back to
"no secrets"). The practical implication: **keep `sc` up to date across your team
and CI** before any newer store format is adopted, so that no older binary is
left unable to read — or, worse, able to overwrite — the new on-disk schema.

## Prerequisites

Before working with secrets, ensure you have:
Expand Down
34 changes: 32 additions & 2 deletions pkg/api/secrets/cryptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package secrets

import (
"errors"
"sync"

"github.com/samber/lo"
Expand All @@ -14,6 +15,32 @@ import (

const EncryptedSecretFilesDataFileName = "secrets.yaml"

// CurrentSecretsSchemaVersion is the highest secrets.yaml schema version this build
// understands. A store with no `schemaVersion` field is treated as version 0 (the
// original, current format). When a future format bumps this, OLDER binaries
// (which carry a lower CurrentSecretsSchemaVersion) refuse to read it — see the
// guard in unmarshalSecretsFile — instead of silently dropping the new fields on
// the next write. This reader must therefore ship and roll out fleet-wide BEFORE
// any higher-versioned store is ever written.
const CurrentSecretsSchemaVersion = 0

// ErrSecretsStoreVersionUnsupported is returned when the on-disk store declares a
// schema version newer than CurrentSecretsSchemaVersion. It MUST stay fatal on every
// read path — including ones that otherwise tolerate a missing/uninitialized store
// (root_cmd's IgnoreConfigDirError) — because reading a too-new store as empty and
// then writing would clobber it. Detect it with errors.Is.
var ErrSecretsStoreVersionUnsupported = errors.New("unsupported secrets store version")

// IsUnsupportedStoreVersion reports whether err indicates the on-disk secrets
// store declares a newer schema version than this build understands. Read paths
// that otherwise tolerate a missing/uninitialized store (the CLI's
// IgnoreConfigDirError, the GitHub Actions "no client secrets -> use parent"
// fallbacks) MUST treat a true result as FATAL and never swallow it as "no
// secrets" — ignoring a too-new store risks a later write clobbering it.
func IsUnsupportedStoreVersion(err error) bool {
return errors.Is(err, ErrSecretsStoreVersionUnsupported)
}

type Cryptor interface {
GenerateKeyPairWithProfile(projectName, profile string) error
GenerateEd25519KeyPairWithProfile(projectName, profile string) error
Expand Down Expand Up @@ -76,8 +103,11 @@ func (c *cryptor) PrivateKey() string {
}

type EncryptedSecretFiles struct {
Registry Registry `json:"registry" yaml:"registry"`
Secrets map[string]EncryptedSecrets `json:"secrets" yaml:"secrets"`
// SchemaVersion is the secrets.yaml schema version. Absent/0 = the original
// format. A reader refuses any value above CurrentSecretsSchemaVersion (fail-closed).
SchemaVersion int `json:"schemaVersion,omitempty" yaml:"schemaVersion,omitempty"`
Registry Registry `json:"registry" yaml:"registry"`
Secrets map[string]EncryptedSecrets `json:"secrets" yaml:"secrets"`
}

type EncryptedSecrets struct {
Expand Down
11 changes: 11 additions & 0 deletions pkg/api/secrets/management.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package secrets
import (
"crypto/rsa"
"encoding/asn1"
"fmt"
"io"
"io/fs"
"os"
Expand Down Expand Up @@ -164,6 +165,16 @@ func (c *cryptor) unmarshalSecretsFile() error {
}
if res, err := api.UnmarshalDescriptor[EncryptedSecretFiles](secretsFileData); err != nil || res == nil {
return errors.Wrapf(err, "failed to unmarshal secrets file: %q", secretsFilePath)
} else if res.SchemaVersion > CurrentSecretsSchemaVersion {
// Fail closed: a newer schema this build doesn't understand. Reading and
// then writing would silently drop the fields we can't model and corrupt
// the store, so refuse outright. Wrap the sentinel so callers that
// otherwise tolerate read errors (root_cmd's IgnoreConfigDirError) can
// still detect this one with errors.Is and keep it fatal.
return fmt.Errorf(
"secrets file %q is schema version %d, but this sc build supports up to schema version %d; upgrade sc (refusing to read to avoid data loss): %w",
secretsFilePath, res.SchemaVersion, CurrentSecretsSchemaVersion, ErrSecretsStoreVersionUnsupported,
)
} else {
c.secrets = *res
// Normalize all public keys to ensure consistency (remove aliases/comments)
Expand Down
66 changes: 66 additions & 0 deletions pkg/api/secrets/version_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
// Copyright (c) Simple Container

package secrets

import (
"errors"
"os"
"path"
"testing"

. "github.com/onsi/gomega"

"github.com/simple-container-com/api/pkg/api"
)

// TestUnmarshalSecretsFile_RejectsNewerVersion is the fail-closed guard: a store
// written by a future schema version must be refused, not partially read (which
// would silently drop the fields this build can't model and corrupt the store).
func TestUnmarshalSecretsFile_RejectsNewerVersion(t *testing.T) {
RegisterTestingT(t)
c, wd, cleanup := newTestCryptor(t)
defer cleanup()

secretsPath := path.Join(wd, api.ScConfigDirectory, EncryptedSecretFilesDataFileName)
Expect(os.WriteFile(secretsPath, []byte("schemaVersion: 99\nregistry:\n files: []\nsecrets: {}\n"), 0o600)).To(Succeed())

err := c.ReadSecretFiles()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("version 99"))
Expect(err.Error()).To(ContainSubstring("refusing to read"))
// Must be detectable as the sentinel: root_cmd relies on errors.Is to keep
// this fatal even on the IgnoreConfigDirError CLI path (else a too-new store
// reads as empty and the next write clobbers it).
Expect(errors.Is(err, ErrSecretsStoreVersionUnsupported)).To(BeTrue())
}

// TestUnmarshalSecretsFile_AcceptsCurrentVersion confirms back-compat: a store
// with no version field (version 0, the original format) reads normally.
func TestUnmarshalSecretsFile_AcceptsCurrentVersion(t *testing.T) {
RegisterTestingT(t)
c, wd, cleanup := newTestCryptor(t)
defer cleanup()

secretsPath := path.Join(wd, api.ScConfigDirectory, EncryptedSecretFilesDataFileName)
Expect(os.WriteFile(secretsPath, []byte("registry:\n files: []\nsecrets: {}\n"), 0o600)).To(Succeed())

Expect(c.ReadSecretFiles()).To(Succeed())
}

// TestIsUnsupportedStoreVersion guards the contract every tolerant read-path
// caller relies on (root_cmd.Init + the GitHub Actions reveal fallbacks): the
// too-new-store error must be detectable via the helper so those callers keep it
// fatal instead of swallowing it as "no secrets".
func TestIsUnsupportedStoreVersion(t *testing.T) {
RegisterTestingT(t)
c, wd, cleanup := newTestCryptor(t)
defer cleanup()

secretsPath := path.Join(wd, api.ScConfigDirectory, EncryptedSecretFilesDataFileName)
Expect(os.WriteFile(secretsPath, []byte("schemaVersion: 99\nregistry:\n files: []\nsecrets: {}\n"), 0o600)).To(Succeed())

Expect(IsUnsupportedStoreVersion(c.ReadSecretFiles())).To(BeTrue())
Expect(IsUnsupportedStoreVersion(nil)).To(BeFalse())
Expect(IsUnsupportedStoreVersion(errors.New("some other read error"))).To(BeFalse())
}
14 changes: 12 additions & 2 deletions pkg/cmd/root_cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/simple-container-com/api/pkg/api"
"github.com/simple-container-com/api/pkg/api/git"
"github.com/simple-container-com/api/pkg/api/logger"
"github.com/simple-container-com/api/pkg/api/secrets"
"github.com/simple-container-com/api/pkg/provisioner"
)

Expand Down Expand Up @@ -108,8 +109,17 @@ func (c *RootCmd) Init(opts InitOpts) error {
if err := c.Provisioner.Cryptor().ReadProfileConfig(); err != nil && !opts.IgnoreConfigDirError {
return errors.Wrapf(err, "failed to read profile config, did you run `init`?")
}
if err := c.Provisioner.Cryptor().ReadSecretFiles(); err != nil && !opts.IgnoreConfigDirError {
return errors.Wrapf(err, "failed to read secrets file, did you run `init`?")
if err := c.Provisioner.Cryptor().ReadSecretFiles(); err != nil {
// A store newer than this build understands is ALWAYS fatal — even when
// config-dir errors are ignored (e.g. IgnoreAllErrors from the root
// PersistentPreRunE). Otherwise the store reads as empty and the next
// write clobbers the newer file, which is exactly what the guard prevents.
if secrets.IsUnsupportedStoreVersion(err) {
return err
}
if !opts.IgnoreConfigDirError {
return errors.Wrapf(err, "failed to read secrets file, did you run `init`?")
}
}

return nil
Expand Down
7 changes: 7 additions & 0 deletions pkg/githubactions/actions/operation_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"github.com/simple-container-com/api/pkg/api"
"github.com/simple-container-com/api/pkg/api/secrets"
)

// OperationType defines the type of operation
Expand Down Expand Up @@ -178,6 +179,9 @@ func (e *Executor) revealSecrets(ctx context.Context, config OperationConfig) er
// First, load the secrets.yaml file into the cryptor
e.logger.Debug(ctx, "🔧 Loading secrets.yaml file into cryptor...")
if err := e.provisioner.Cryptor().ReadSecretFiles(); err != nil {
if secrets.IsUnsupportedStoreVersion(err) {
return err // fail closed: never treat a too-new store as "no secrets"
}
e.logger.Info(ctx, "ℹ️ No client secrets found - using parent repository secrets")
return nil // No secrets to reveal, will use parent secrets
}
Expand All @@ -203,6 +207,9 @@ func (e *Executor) revealSecrets(ctx context.Context, config OperationConfig) er
// First, load the secrets.yaml file into the cryptor
e.logger.Debug(ctx, "🔧 Loading secrets.yaml file into cryptor...")
if err := e.provisioner.Cryptor().ReadSecretFiles(); err != nil {
if secrets.IsUnsupportedStoreVersion(err) {
return err // fail closed: never treat a too-new store as "no secrets"
}
e.logger.Warn(ctx, "Failed to read secrets file: %v", err)
e.logger.Info(ctx, "🔍 This is expected if parent repository has no secrets")
return nil // No secrets to reveal
Expand Down
6 changes: 6 additions & 0 deletions pkg/githubactions/actions/parent_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ func (e *Executor) cloneParentRepository(ctx context.Context) error {
if !isParentOperation && scConfig.ParentRepository != "" {
e.logger.Info(ctx, "🔑 Client stack with parent repository - revealing secrets in current workspace...")
if err := e.revealCurrentRepositorySecrets(ctx, scConfig); err != nil {
if secrets.IsUnsupportedStoreVersion(err) {
return err // fail closed: a too-new store must halt, not fall back to parent
}
e.logger.Warn(ctx, "Failed to reveal client secrets (may use parent secrets): %v", err)
// Don't fail the entire deployment - parent secrets might be sufficient
} else {
Expand Down Expand Up @@ -391,6 +394,9 @@ func (e *Executor) setupParentRepositorySecrets(ctx context.Context, scConfig *a

e.logger.Info(ctx, "🔧 Loading secrets.yaml file into cryptor...")
if err := parentCryptor.ReadSecretFiles(); err != nil {
if secrets.IsUnsupportedStoreVersion(err) {
return false, err // fail closed: never treat a too-new store as "no secrets"
}
e.logger.Warn(ctx, "Failed to read secrets file: %v", err)
e.logger.Info(ctx, "🔍 This is expected if parent repository has no secrets")
return false, nil // No secrets loaded, but not an error
Expand Down
Loading