Skip to content
Merged
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
13 changes: 10 additions & 3 deletions docs/docs/guides/secrets-management.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Secrets Management with Simple Container

This comprehensive guide covers how to manage secrets and confidential files using Simple Container's built-in secrets management system. Simple Container uses SSH-RSA encryption to securely store and share secrets within your team while maintaining them in your Git repository.
This comprehensive guide covers how to manage secrets and confidential files using Simple Container's built-in secrets management system. Simple Container encrypts secrets to your team's SSH public keys (RSA and ed25519) so they can be stored and shared safely in your Git repository.

## Overview

Simple Container's secrets management provides:

- **SSH-RSA encryption** for secure secret storage
- **Public-key encryption** to SSH recipients (RSA and ed25519) for secure secret storage
- **Team-based access control** with public key management
- **Git-native workflow** for secret versioning and collaboration
- **Built-in commands** for easy secret lifecycle management
Expand All @@ -24,12 +24,19 @@ Simple Container uses a multi-key encryption approach:

This means that when you run `sc secrets reveal`, the system uses your private key to decrypt secrets that were encrypted with your corresponding public key (along with all other team members' public keys).

### Cipher per recipient type

Each recipient public key gets its own encryption of the secret, using a scheme appropriate to the key type:

- **`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.

## Prerequisites

Before working with secrets, ensure you have:

- Simple Container CLI installed
- SSH RSA key pair (2048-bit supported)
- An SSH key pair — RSA (2048-bit) or ed25519
- Access to the project repository

## Configuration Override
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.4

require (
cloud.google.com/go/storage v1.62.3
filippo.io/edwards25519 v1.2.0
github.com/MShekow/directory-checksum v1.4.18
github.com/anthonycorbacho/slack-webhook v1.0.1
github.com/antonmedv/expr v1.12.6
Expand All @@ -19,6 +20,7 @@ require (
github.com/compose-spec/compose-go v1.20.2
github.com/containerd/platforms v0.2.1
github.com/disgoorg/disgo v0.19.6
github.com/disgoorg/snowflake/v2 v2.0.3
github.com/fatih/color v1.19.0
github.com/go-delve/delve v1.26.3
github.com/go-git/go-billy/v5 v5.9.0
Expand Down Expand Up @@ -182,7 +184,6 @@ require (
github.com/derekparker/trie/v3 v3.2.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/disgoorg/omit v1.0.0 // indirect
github.com/disgoorg/snowflake/v2 v2.0.3 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.5.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E=
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
Expand Down
7 changes: 4 additions & 3 deletions pkg/api/secrets/ciphers/coverage_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,10 @@ func TestDecryptLargeStringWithEd25519_TruncatedAfterHeader(t *testing.T) {
raw, err := base64.StdEncoding.DecodeString(enc[0])
Expect(err).ToNot(HaveOccurred())

// Keep salt(32)+nonce(12) and a single body byte: passes the length guard
// (>= 44) but cannot hold a 16-byte AEAD tag, so Open fails.
truncated := base64.StdEncoding.EncodeToString(raw[:45])
// Keep the X25519 header + a truncated ciphertext: longer than the 69-byte
// minimum (magic+ver+ephPub+nonce+tag) so it passes the length guard, but
// corrupted, so Open fails.
truncated := base64.StdEncoding.EncodeToString(raw[:70])
_, err = DecryptLargeStringWithEd25519(priv, []string{truncated})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to decrypt data"))
Expand Down
62 changes: 20 additions & 42 deletions pkg/api/secrets/ciphers/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,13 @@ func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {
res[idx] = base64.StdEncoding.EncodeToString(encryptedData)
}
} else if ed25519Key, ok := key.(ed25519.PublicKey); ok {
// For ed25519, use hybrid encryption with Curve25519 + ChaCha20-Poly1305
encryptedData, err := encryptWithEd25519(ed25519Key, []byte(s))
// ed25519 recipients are sealed via ephemeral-static X25519 ECDH
// (see x25519.go): the AEAD key is derived from the ECDH shared secret,
// so only the holder of the private key can decrypt. The previous scheme
// derived the key from the public key alone and is no longer produced.
encryptedData, err := encryptWithX25519(ed25519Key, []byte(s))
if err != nil {
return nil, errors.Wrapf(err, "failed to encrypt secret with ed25519")
return nil, errors.Wrapf(err, "failed to encrypt secret for ed25519 recipient")
}
res = []string{base64.StdEncoding.EncodeToString(encryptedData)}
} else {
Expand Down Expand Up @@ -199,49 +202,24 @@ func DecryptLargeStringWithEd25519(key ed25519.PrivateKey, chunks []string) ([]b
if err != nil {
return nil, errors.Wrapf(err, "failed to decode base64 string")
}
// New blobs use the X25519 sealed box (see x25519.go). Legacy blobs predate
// it and are read-only here solely to support migration/re-encryption — they
// are NOT confidential (the key was derived from public data); rotate any
// secret ever stored in one.
if isX25519Blob(chunkBytes) {
return decryptWithX25519(key, chunkBytes)
}
return decryptWithEd25519(key, chunkBytes)
}

// encryptWithEd25519 performs hybrid encryption using HKDF key derivation and ChaCha20-Poly1305
func encryptWithEd25519(publicKey ed25519.PublicKey, plaintext []byte) ([]byte, error) {
// Generate a random salt for HKDF
salt := make([]byte, 32)
if _, err := rand.Read(salt); err != nil {
return nil, errors.Wrap(err, "failed to generate salt")
}

// Use HKDF to derive encryption key from ed25519 public key and salt
hkdfReader := hkdf.New(sha256.New, publicKey, salt, []byte("ed25519-chacha20poly1305"))
encryptionKey := make([]byte, 32)
if _, err := hkdfReader.Read(encryptionKey); err != nil {
return nil, errors.Wrap(err, "failed to derive encryption key")
}

// Create ChaCha20-Poly1305 cipher
cipher, err := chacha20poly1305.New(encryptionKey)
if err != nil {
return nil, errors.Wrap(err, "failed to create cipher")
}

// Generate nonce
nonce := make([]byte, chacha20poly1305.NonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, errors.Wrap(err, "failed to generate nonce")
}

// Encrypt the plaintext
ciphertext := cipher.Seal(nil, nonce, plaintext, nil)

// Combine salt + nonce + ciphertext
result := make([]byte, 32+len(nonce)+len(ciphertext))
copy(result[0:32], salt)
copy(result[32:32+len(nonce)], nonce)
copy(result[32+len(nonce):], ciphertext)

return result, nil
}
// NOTE: the former encryptWithEd25519 was removed. It derived the AEAD key from
// the recipient's PUBLIC key (HKDF(publicKey, salt)) with no key agreement, so
// the ciphertext was decryptable by anyone holding the public key — i.e. zero
// confidentiality. ed25519 recipients are now sealed via X25519 ECDH (x25519.go).

// decryptWithEd25519 performs hybrid decryption using HKDF key derivation and ChaCha20-Poly1305
// decryptWithEd25519 reads a LEGACY (pre-X25519) ed25519 blob. Kept only so old
// stores can be decrypted for migration/re-encryption. Such blobs are NOT
// confidential — rotate any secret ever stored in one. New blobs use X25519.
func decryptWithEd25519(privateKey ed25519.PrivateKey, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < 32+chacha20poly1305.NonceSize {
return nil, errors.New("ciphertext too short")
Expand Down
188 changes: 188 additions & 0 deletions pkg/api/secrets/ciphers/x25519.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package ciphers

import (
"bytes"
"crypto/ecdh"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"io"

"filippo.io/edwards25519"
"github.com/pkg/errors"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
)

// x25519Magic marks a sealed-box blob produced by encryptWithX25519, so
// DecryptLargeStringWithEd25519 can route new blobs to the X25519 path while
// still reading legacy (pre-fix) blobs during migration. Legacy blobs began
// with a 32-byte random salt, so this fixed 8-byte prefix collides with a
// legacy blob with probability 2^-64.
var x25519Magic = []byte("scx25519")

const x25519Version byte = 1

// ed25519PublicKeyToX25519 maps an Ed25519 public key (an Edwards point) to the
// equivalent X25519 (Montgomery u-coordinate) public key — the standard
// birational map also used by age/agessh. Conversion is delegated to the vetted
// filippo.io/edwards25519 rather than hand-rolled.
func ed25519PublicKeyToX25519(pub ed25519.PublicKey) ([]byte, error) {
if len(pub) != ed25519.PublicKeySize {
return nil, errors.Errorf("invalid ed25519 public key size: %d", len(pub))
}
p, err := new(edwards25519.Point).SetBytes(pub)
if err != nil {
return nil, errors.Wrap(err, "failed to decode ed25519 public key as an edwards point")
}
return p.BytesMontgomery(), nil
}

// ed25519PrivateKeyToX25519 maps an Ed25519 private key to the equivalent X25519
// private scalar: clamp(SHA-512(seed)[:32]) per RFC 8032 / RFC 7748. Clamping is
// idempotent; crypto/ecdh also clamps internally on use.
func ed25519PrivateKeyToX25519(priv ed25519.PrivateKey) []byte {
h := sha512.Sum512(priv.Seed())
s := h[:32]
s[0] &= 248
s[31] &= 127
s[31] |= 64
return s
}

// encryptWithX25519 seals plaintext to an Ed25519 recipient using ephemeral-static
// X25519 ECDH → HKDF-SHA256 → ChaCha20-Poly1305 (a sealed box). The AEAD key is
// derived from the ECDH *shared secret*, so only the recipient's private key can
// decrypt — unlike the removed legacy scheme, whose key was derived from the
// public key alone (and was therefore decryptable by anyone with read access).
//
// Blob layout: magic(8) | version(1) | ephPub(32) | nonce(12) | ciphertext.
func encryptWithX25519(recipientEd25519Pub ed25519.PublicKey, plaintext []byte) ([]byte, error) {
xPub, err := ed25519PublicKeyToX25519(recipientEd25519Pub)
if err != nil {
return nil, err
}
curve := ecdh.X25519()
recipientPub, err := curve.NewPublicKey(xPub)
if err != nil {
return nil, errors.Wrap(err, "failed to build recipient X25519 public key")
}
ephPriv, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, errors.Wrap(err, "failed to generate ephemeral X25519 key")
}
ephPub := ephPriv.PublicKey().Bytes()
shared, err := ephPriv.ECDH(recipientPub)
if err != nil {
return nil, errors.Wrap(err, "ephemeral ECDH failed")
}
key, err := deriveX25519Key(shared, ephPub, xPub)
if err != nil {
return nil, err
}
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, x25519AAD(ephPub))

blob := make([]byte, 0, len(x25519Magic)+1+len(ephPub)+len(nonce)+len(ct))
blob = append(blob, x25519Magic...)
blob = append(blob, x25519Version)
blob = append(blob, ephPub...)
blob = append(blob, nonce...)
blob = append(blob, ct...)
return blob, nil
}

// decryptWithX25519 reverses encryptWithX25519 using the recipient's Ed25519
// private key (converted to X25519).
func decryptWithX25519(recipientEd25519Priv ed25519.PrivateKey, blob []byte) ([]byte, error) {
// ed25519.PrivateKey.Seed() panics on a wrong-sized key; fail safely instead.
if len(recipientEd25519Priv) != ed25519.PrivateKeySize {
return nil, errors.Errorf("invalid ed25519 private key size: %d", len(recipientEd25519Priv))
}
// Minimum = magic + version + ephPub + nonce + the 16-byte AEAD tag. Reject
// undersized blobs before doing ECDH/HKDF so trivially-invalid input can't
// burn CPU.
headerLen := len(x25519Magic) + 1 + 32 + chacha20poly1305.NonceSize + chacha20poly1305.Overhead
if len(blob) < headerLen {
return nil, errors.New("x25519 ciphertext too short")
}
off := len(x25519Magic)
if !bytes.Equal(blob[:off], x25519Magic) {
return nil, errors.New("not an x25519 blob")
}
if blob[off] != x25519Version {
return nil, errors.Errorf("unsupported x25519 blob version: %d", blob[off])
}
off++
ephPub := blob[off : off+32]
off += 32
nonce := blob[off : off+chacha20poly1305.NonceSize]
off += chacha20poly1305.NonceSize
ct := blob[off:]

curve := ecdh.X25519()
xPriv, err := curve.NewPrivateKey(ed25519PrivateKeyToX25519(recipientEd25519Priv))
if err != nil {
return nil, errors.Wrap(err, "failed to build recipient X25519 private key")
}
ephPubKey, err := curve.NewPublicKey(ephPub)
if err != nil {
return nil, errors.Wrap(err, "failed to parse ephemeral public key")
}
shared, err := xPriv.ECDH(ephPubKey)
if err != nil {
return nil, errors.Wrap(err, "ECDH failed")
}
key, err := deriveX25519Key(shared, ephPub, xPriv.PublicKey().Bytes())
if err != nil {
return nil, err
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, errors.Wrap(err, "failed to create AEAD")
}
pt, err := aead.Open(nil, nonce, ct, x25519AAD(ephPub))
if err != nil {
return nil, errors.Wrap(err, "failed to decrypt data")
}
return pt, nil
}

// deriveX25519Key derives the AEAD key from the ECDH shared secret, binding it to
// both public keys so a wrapped blob cannot be transplanted between recipients.
func deriveX25519Key(shared, ephPub, recipientXPub []byte) ([]byte, error) {
info := make([]byte, 0, len(x25519Magic)+1+len(ephPub)+len(recipientXPub))
info = append(info, x25519Magic...)
info = append(info, x25519Version)
info = append(info, ephPub...)
info = append(info, recipientXPub...)
r := hkdf.New(sha256.New, shared, nil, info)
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(r, key); err != nil {
return nil, errors.Wrap(err, "failed to derive key")
}
return key, nil
}

// x25519AAD binds the format magic, version, and ephemeral public key into the
// AEAD's associated data so none of them can be altered without failing Open.
func x25519AAD(ephPub []byte) []byte {
aad := make([]byte, 0, len(x25519Magic)+1+len(ephPub))
aad = append(aad, x25519Magic...)
aad = append(aad, x25519Version)
aad = append(aad, ephPub...)
return aad
}

// isX25519Blob reports whether b was produced by encryptWithX25519.
func isX25519Blob(b []byte) bool {
return len(b) >= len(x25519Magic) && bytes.Equal(b[:len(x25519Magic)], x25519Magic)
}
Loading
Loading