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
4 changes: 2 additions & 2 deletions .github/workflows/branch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ permissions:
jobs:
build:
name: Build simple-container in branch
runs-on: self-hosted
runs-on: blacksmith-32vcpu-ubuntu-2204
outputs:
cicd-bot-telegram-token: ${{ steps.prepare-secrets.outputs.cicd-bot-telegram-token }}
cicd-bot-telegram-chat-id: ${{ steps.prepare-secrets.outputs.cicd-bot-telegram-chat-id }}
Expand Down Expand Up @@ -42,7 +42,7 @@ jobs:

finalize:
name: Finalize build in branch
runs-on: self-hosted
runs-on: ubuntu-latest
if: ${{ always() }}
permissions:
contents: write
Expand Down
22 changes: 22 additions & 0 deletions SYSTEM_PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ For comprehensive patterns research, refer to `REAL_WORLD_EXAMPLES_MAP.md` which
- Verified MongoDB Atlas and Redis resources rely on compute processor auto-injection of environment variables
- Final verification confirmed zero remaining instances of fictional `connectionString` property

## Cryptographic Capabilities
### Public Key Support
- **RSA 2048+**: Traditional RSA encryption using OAEP padding with SHA-256/SHA-512
- **ED25519**: Modern elliptic curve cryptography using HKDF-based approach
- **Key Derivation**: HKDF-SHA256 derives encryption keys from ed25519 public key + random salt
- **Symmetric Encryption**: ChaCha20-Poly1305 authenticated encryption
- **Security**: Non-deterministic encryption with random salts for each operation
- **Format**: SSH authorized key format for public keys, PKCS#8 for private keys

### Encryption Functions
- `EncryptLargeString()` - Auto-detects and supports both RSA and ed25519 public keys
- `DecryptLargeString()` - RSA decryption with chunked support
- `DecryptLargeStringWithEd25519()` - Ed25519 HKDF-based decryption
- `GenerateKeyPair()` - RSA key pair generation
- `GenerateEd25519KeyPair()` - Ed25519 key pair generation

### Cryptor Integration
- `GenerateEd25519KeyPairWithProfile()` - Creates ed25519 keys with profile configuration
- `WithGeneratedEd25519Keys()` - Option for ed25519 key generation in cryptor
- Automatic key type detection in decryption process
- Full backward compatibility with existing RSA workflows

## Key Learnings
- Always verify actual struct definitions before documenting resource properties
- Use real-world examples from the aiwayz-sc-config project for accurate documentation
Expand Down
135 changes: 135 additions & 0 deletions pkg/api/secrets/alias_deduplication_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package secrets

import (
"strings"
"testing"

. "github.com/onsi/gomega"

"github.com/simple-container-com/api/pkg/api/git"
"github.com/simple-container-com/api/pkg/api/secrets/ciphers"
"github.com/simple-container-com/api/pkg/api/tests/testutil"
"github.com/simple-container-com/api/pkg/util/test"
)

func TestAddPublicKeyAliasDeduplication(t *testing.T) {
RegisterTestingT(t)

// Setup a test cryptor
workDir, cleanup, err := testutil.CopyTempProject("testdata/repo")
defer cleanup()
Expect(err).To(BeNil())

mocks := &mocks{
consoleReaderMock: &test.ConsoleReaderMock{},
confirmationReaderMock: &test.ConsoleReaderMock{},
}
mocks.confirmationReaderMock.On("ReadLine").Return("Y", nil)

repo, err := git.Open(workDir, git.WithGitDir("gitdir"))
Expect(err).To(BeNil())

cryptor, err := NewCryptor(workDir,
WithKeysFromScConfig("local-key-files"),
WithGitRepo(repo),
WithConsoleReader(mocks.consoleReaderMock),
WithConfirmationReader(mocks.confirmationReaderMock),
)
Expect(err).To(BeNil())

// Generate test keys
_, rsaPubKey, err := ciphers.GenerateKeyPair(2048)
Expect(err).To(BeNil())
rsaPubKeySSH, err := ciphers.MarshalPublicKey(rsaPubKey)
Expect(err).To(BeNil())

_, ed25519PubKey, err := ciphers.GenerateEd25519KeyPair()
Expect(err).To(BeNil())
ed25519PubKeySSH, err := ciphers.MarshalEd25519PublicKey(ed25519PubKey)
Expect(err).To(BeNil())

// Create keys with and without aliases
rsaKeyWithoutAlias := strings.TrimSpace(string(rsaPubKeySSH))
rsaKeyWithAlias1 := rsaKeyWithoutAlias + " user1@host1"

ed25519KeyWithoutAlias := strings.TrimSpace(string(ed25519PubKeySSH))
ed25519KeyWithAlias1 := ed25519KeyWithoutAlias + " dev@laptop"
ed25519KeyWithAlias2 := ed25519KeyWithoutAlias + " prod@server"

t.Run("RSA key - add without alias first", func(t *testing.T) {
initialKeys := cryptor.GetKnownPublicKeys()
initialCount := len(initialKeys)

// Add RSA key without alias
err := cryptor.AddPublicKey(rsaKeyWithoutAlias)
Expect(err).To(BeNil())

keysAfterFirst := cryptor.GetKnownPublicKeys()
Expect(len(keysAfterFirst)).To(Equal(initialCount + 1))
Expect(keysAfterFirst).To(ContainElement(rsaKeyWithoutAlias))

// Add the same RSA key with alias - should this create a duplicate?
err = cryptor.AddPublicKey(rsaKeyWithAlias1)
Expect(err).To(BeNil())

keysAfterAlias := cryptor.GetKnownPublicKeys()
t.Logf("Keys after adding with alias: %v", keysAfterAlias)
t.Logf("Key count: initial=%d, after_first=%d, after_alias=%d",
initialCount, len(keysAfterFirst), len(keysAfterAlias))

if len(keysAfterAlias) == initialCount+1 {
t.Log("✅ GOOD: Same key with alias was deduplicated")
Expect(keysAfterAlias).To(ContainElement(rsaKeyWithoutAlias))
Expect(keysAfterAlias).NotTo(ContainElement(rsaKeyWithAlias1))
} else {
t.Log("❌ ISSUE: Same key with alias created duplicate entry")
}
})

t.Run("RSA key - add with alias first", func(t *testing.T) {
// Use different alias to avoid conflicts
rsaKeyWithAlias3 := rsaKeyWithoutAlias + " different@host"

initialKeys := cryptor.GetKnownPublicKeys()
initialCount := len(initialKeys)

// Add RSA key with alias first
err := cryptor.AddPublicKey(rsaKeyWithAlias3)
Expect(err).To(BeNil())

keysAfterFirst := cryptor.GetKnownPublicKeys()
t.Logf("Keys after adding with alias first: %v", keysAfterFirst)

if len(keysAfterFirst) == initialCount+1 {
if keysAfterFirst[len(keysAfterFirst)-1] == rsaKeyWithoutAlias {
t.Log("✅ GOOD: Key with alias was stored in normalized form")
} else {
t.Log("❌ ISSUE: Key with alias was stored with alias intact")
}
}
})

t.Run("Ed25519 key - multiple aliases of same key", func(t *testing.T) {
initialKeys := cryptor.GetKnownPublicKeys()
initialCount := len(initialKeys)

// Add multiple versions with different aliases
err := cryptor.AddPublicKey(ed25519KeyWithAlias1)
Expect(err).To(BeNil())
err = cryptor.AddPublicKey(ed25519KeyWithAlias2)
Expect(err).To(BeNil())
err = cryptor.AddPublicKey(ed25519KeyWithoutAlias)
Expect(err).To(BeNil())

keysAfterMultiple := cryptor.GetKnownPublicKeys()
t.Logf("Keys after adding multiple aliases: %v", keysAfterMultiple)
t.Logf("Key count: initial=%d, after_multiple=%d", initialCount, len(keysAfterMultiple))

if len(keysAfterMultiple) == initialCount+1 {
t.Log("✅ GOOD: Multiple aliases of same ed25519 key were deduplicated")
Expect(keysAfterMultiple).To(ContainElement(ed25519KeyWithoutAlias))
} else {
t.Log("❌ ISSUE: Multiple aliases created duplicate entries")
}
})
}
134 changes: 130 additions & 4 deletions pkg/api/secrets/ciphers/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import (
"encoding/pem"
"strings"

"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/ed25519"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/ssh"

"github.com/pkg/errors"
"github.com/samber/lo"
)

// GenerateKeyPair generates a new key pair
// GenerateKeyPair generates a new RSA key pair
func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) {
privkey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
Expand All @@ -27,6 +29,15 @@ func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) {
return privkey, &privkey.PublicKey, nil
}

// GenerateEd25519KeyPair generates a new ed25519 key pair
func GenerateEd25519KeyPair() (ed25519.PrivateKey, ed25519.PublicKey, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
return priv, pub, nil
}

// PrivateKeyToBytes private key to bytes
func PrivateKeyToBytes(priv *rsa.PrivateKey) []byte {
privBytes := pem.EncodeToMemory(
Expand Down Expand Up @@ -97,6 +108,27 @@ func MarshalRSAPrivateKey(priv *rsa.PrivateKey) string {
}))
}

// MarshalEd25519PrivateKey marshals an ed25519 private key to PEM format
func MarshalEd25519PrivateKey(priv ed25519.PrivateKey) (string, error) {
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privBytes,
})), nil
}

// MarshalEd25519PublicKey marshals an ed25519 public key to SSH authorized key format
func MarshalEd25519PublicKey(pub ed25519.PublicKey) ([]byte, error) {
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
return nil, err
}
return ssh.MarshalAuthorizedKey(sshPub), nil
}

func ParsePublicKey(s string) (crypto.PublicKey, error) {
parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s))
if err != nil {
Expand All @@ -106,7 +138,7 @@ func ParsePublicKey(s string) (crypto.PublicKey, error) {
if parsedCryptoKey, ok := parsed.(ssh.CryptoPublicKey); !ok {
return nil, errors.New("failed to parse public key: not a CryptoPublicKey")
} else if res, ok := parsedCryptoKey.CryptoPublicKey().(crypto.PublicKey); !ok { //nolint: gosimple
return nil, errors.New("failed to parse public key: not a RSA public key")
return nil, errors.New("failed to parse public key: not a supported public key type")
} else {
return res, nil
}
Expand All @@ -124,8 +156,15 @@ func EncryptLargeString(key crypto.PublicKey, s string) ([]string, error) {
}
res[idx] = base64.StdEncoding.EncodeToString(encryptedData)
}
} else if _, ok := key.(ed25519.PublicKey); ok {
return res, errors.New("ed25519 encryption is not supported")
} else if ed25519Key, ok := key.(ed25519.PublicKey); ok {
// For ed25519, use hybrid encryption with Curve25519 + ChaCha20-Poly1305
encryptedData, err := encryptWithEd25519(ed25519Key, []byte(s))
if err != nil {
return nil, errors.Wrapf(err, "failed to encrypt secret with ed25519")
}
res = []string{base64.StdEncoding.EncodeToString(encryptedData)}
} else {
return nil, errors.New("unsupported key type for encryption")
}
return res, nil
}
Expand All @@ -147,3 +186,90 @@ func DecryptLargeString(key *rsa.PrivateKey, chunks []string) ([]byte, error) {
return string(chunk)
}), "")), nil
}

// DecryptLargeStringWithEd25519 decrypts data encrypted with ed25519 hybrid encryption
func DecryptLargeStringWithEd25519(key ed25519.PrivateKey, chunks []string) ([]byte, error) {
if len(chunks) != 1 {
return nil, errors.New("ed25519 decryption expects exactly one chunk")
}
chunkBytes, err := base64.StdEncoding.DecodeString(chunks[0])
if err != nil {
return nil, errors.Wrapf(err, "failed to decode base64 string")
}
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
}

// decryptWithEd25519 performs hybrid decryption using HKDF key derivation and ChaCha20-Poly1305
func decryptWithEd25519(privateKey ed25519.PrivateKey, ciphertext []byte) ([]byte, error) {
if len(ciphertext) < 32+chacha20poly1305.NonceSize {
return nil, errors.New("ciphertext too short")
}

// Extract components
salt := ciphertext[0:32]
nonce := ciphertext[32 : 32+chacha20poly1305.NonceSize]
encryptedData := ciphertext[32+chacha20poly1305.NonceSize:]

// Derive the public key from the private key for HKDF
publicKey := privateKey.Public().(ed25519.PublicKey)

// Use HKDF to derive the same encryption key using the 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")
}

// Decrypt the data
plaintext, err := cipher.Open(nil, nonce, encryptedData, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to decrypt data")
}

return plaintext, nil
}
Loading