diff --git a/.github/workflows/branch.yaml b/.github/workflows/branch.yaml index 9695e359..dc827839 100644 --- a/.github/workflows/branch.yaml +++ b/.github/workflows/branch.yaml @@ -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 }} @@ -42,7 +42,7 @@ jobs: finalize: name: Finalize build in branch - runs-on: self-hosted + runs-on: ubuntu-latest if: ${{ always() }} permissions: contents: write diff --git a/SYSTEM_PROMPT.md b/SYSTEM_PROMPT.md index b69ae2e3..2e000c10 100644 --- a/SYSTEM_PROMPT.md +++ b/SYSTEM_PROMPT.md @@ -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 diff --git a/pkg/api/secrets/alias_deduplication_test.go b/pkg/api/secrets/alias_deduplication_test.go new file mode 100644 index 00000000..b811969e --- /dev/null +++ b/pkg/api/secrets/alias_deduplication_test.go @@ -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") + } + }) +} diff --git a/pkg/api/secrets/ciphers/encryption.go b/pkg/api/secrets/ciphers/encryption.go index 9508258d..0424338d 100644 --- a/pkg/api/secrets/ciphers/encryption.go +++ b/pkg/api/secrets/ciphers/encryption.go @@ -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 { @@ -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( @@ -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 { @@ -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 } @@ -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 } @@ -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 +} diff --git a/pkg/api/secrets/ciphers/encryption_test.go b/pkg/api/secrets/ciphers/encryption_test.go new file mode 100644 index 00000000..af2d1772 --- /dev/null +++ b/pkg/api/secrets/ciphers/encryption_test.go @@ -0,0 +1,287 @@ +package ciphers + +import ( + "crypto" + "strings" + "testing" + + "golang.org/x/crypto/ed25519" + + . "github.com/onsi/gomega" +) + +func TestGenerateKeyPair(t *testing.T) { + RegisterTestingT(t) + + t.Run("RSA 2048 key generation", func(t *testing.T) { + privKey, pubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + Expect(privKey).NotTo(BeNil()) + Expect(pubKey).NotTo(BeNil()) + Expect(privKey.N.BitLen()).To(Equal(2048)) + }) + + t.Run("RSA 4096 key generation", func(t *testing.T) { + privKey, pubKey, err := GenerateKeyPair(4096) + Expect(err).To(BeNil()) + Expect(privKey).NotTo(BeNil()) + Expect(pubKey).NotTo(BeNil()) + Expect(privKey.N.BitLen()).To(Equal(4096)) + }) +} + +func TestGenerateEd25519KeyPair(t *testing.T) { + RegisterTestingT(t) + + t.Run("ed25519 key generation", func(t *testing.T) { + privKey, pubKey, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + Expect(privKey).NotTo(BeNil()) + Expect(pubKey).NotTo(BeNil()) + Expect(len(privKey)).To(Equal(ed25519.PrivateKeySize)) + Expect(len(pubKey)).To(Equal(ed25519.PublicKeySize)) + }) + + t.Run("ed25519 keys are different", func(t *testing.T) { + privKey1, pubKey1, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + privKey2, pubKey2, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + Expect(privKey1).NotTo(Equal(privKey2)) + Expect(pubKey1).NotTo(Equal(pubKey2)) + }) +} + +func TestMarshalEd25519Keys(t *testing.T) { + RegisterTestingT(t) + + privKey, pubKey, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + t.Run("marshal ed25519 private key", func(t *testing.T) { + pemKey, err := MarshalEd25519PrivateKey(privKey) + Expect(err).To(BeNil()) + Expect(pemKey).To(ContainSubstring("-----BEGIN PRIVATE KEY-----")) + Expect(pemKey).To(ContainSubstring("-----END PRIVATE KEY-----")) + }) + + t.Run("marshal ed25519 public key", func(t *testing.T) { + sshKey, err := MarshalEd25519PublicKey(pubKey) + Expect(err).To(BeNil()) + Expect(string(sshKey)).To(HavePrefix("ssh-ed25519 ")) + }) +} + +func TestRSAEncryptionDecryption(t *testing.T) { + RegisterTestingT(t) + + privKey, pubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + testData := "Hello, World! This is a test message for RSA encryption." + + t.Run("RSA encrypt/decrypt small message", func(t *testing.T) { + encrypted, err := EncryptWithPublicRSAKey([]byte(testData), pubKey) + Expect(err).To(BeNil()) + Expect(encrypted).NotTo(BeEmpty()) + + decrypted, err := DecryptWithPrivateRSAKey(encrypted, privKey) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(testData)) + }) + + t.Run("RSA encrypt/decrypt large string", func(t *testing.T) { + largeData := strings.Repeat(testData, 20) // Create a large string + + encryptedChunks, err := EncryptLargeString(pubKey, largeData) + Expect(err).To(BeNil()) + Expect(encryptedChunks).NotTo(BeEmpty()) + + decrypted, err := DecryptLargeString(privKey, encryptedChunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(largeData)) + }) +} + +func TestEd25519EncryptionDecryption(t *testing.T) { + RegisterTestingT(t) + + privKey, pubKey, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + testData := "Hello, World! This is a test message for ed25519 encryption." + + t.Run("ed25519 encrypt/decrypt small message", func(t *testing.T) { + encryptedChunks, err := EncryptLargeString(pubKey, testData) + Expect(err).To(BeNil()) + Expect(encryptedChunks).To(HaveLen(1)) // ed25519 should return exactly one chunk + + decrypted, err := DecryptLargeStringWithEd25519(privKey, encryptedChunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(testData)) + }) + + t.Run("ed25519 encrypt/decrypt large message", func(t *testing.T) { + largeData := strings.Repeat(testData, 100) // Create a very large string + + encryptedChunks, err := EncryptLargeString(pubKey, largeData) + Expect(err).To(BeNil()) + Expect(encryptedChunks).To(HaveLen(1)) // ed25519 should still return exactly one chunk + + decrypted, err := DecryptLargeStringWithEd25519(privKey, encryptedChunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(largeData)) + }) + + t.Run("ed25519 encryption is non-deterministic", func(t *testing.T) { + // Each encryption should produce different results due to ephemeral keys + encrypted1, err := EncryptLargeString(pubKey, testData) + Expect(err).To(BeNil()) + + encrypted2, err := EncryptLargeString(pubKey, testData) + Expect(err).To(BeNil()) + + Expect(encrypted1[0]).NotTo(Equal(encrypted2[0])) + }) +} + +func TestParsePublicKey(t *testing.T) { + RegisterTestingT(t) + + t.Run("parse RSA public key", func(t *testing.T) { + _, rsaPubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + sshKey, err := MarshalPublicKey(rsaPubKey) + Expect(err).To(BeNil()) + + parsedKey, err := ParsePublicKey(string(sshKey)) + Expect(err).To(BeNil()) + Expect(parsedKey).NotTo(BeNil()) + }) + + t.Run("parse ed25519 public key", func(t *testing.T) { + _, ed25519PubKey, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + sshKey, err := MarshalEd25519PublicKey(ed25519PubKey) + Expect(err).To(BeNil()) + + parsedKey, err := ParsePublicKey(string(sshKey)) + Expect(err).To(BeNil()) + Expect(parsedKey).NotTo(BeNil()) + + // Verify it's actually an ed25519 key + _, ok := parsedKey.(ed25519.PublicKey) + Expect(ok).To(BeTrue()) + }) + + t.Run("parse invalid public key", func(t *testing.T) { + _, err := ParsePublicKey("invalid-key-data") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("ssh: no key found")) + }) +} + +func TestCrossKeyTypeCompatibility(t *testing.T) { + RegisterTestingT(t) + + // Generate both RSA and ed25519 keys + rsaPrivKey, rsaPubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + ed25519PrivKey, ed25519PubKey, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + testData := "Cross-compatibility test message" + + t.Run("EncryptLargeString detects RSA keys", func(t *testing.T) { + encryptedChunks, err := EncryptLargeString(rsaPubKey, testData) + Expect(err).To(BeNil()) + Expect(len(encryptedChunks)).To(BeNumerically(">", 0)) + + decrypted, err := DecryptLargeString(rsaPrivKey, encryptedChunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(testData)) + }) + + t.Run("EncryptLargeString detects ed25519 keys", func(t *testing.T) { + encryptedChunks, err := EncryptLargeString(ed25519PubKey, testData) + Expect(err).To(BeNil()) + Expect(encryptedChunks).To(HaveLen(1)) + + decrypted, err := DecryptLargeStringWithEd25519(ed25519PrivKey, encryptedChunks) + Expect(err).To(BeNil()) + Expect(string(decrypted)).To(Equal(testData)) + }) + + t.Run("unsupported key type", func(t *testing.T) { + var unsupportedKey crypto.PublicKey = struct{}{} + _, err := EncryptLargeString(unsupportedKey, testData) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unsupported key type")) + }) +} + +func TestEd25519DecryptionEdgeCases(t *testing.T) { + RegisterTestingT(t) + + privKey, _, err := GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + + t.Run("decrypt with wrong number of chunks", func(t *testing.T) { + // ed25519 expects exactly one chunk + multipleChunks := []string{"chunk1", "chunk2"} + _, err := DecryptLargeStringWithEd25519(privKey, multipleChunks) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("expects exactly one chunk")) + }) + + t.Run("decrypt with invalid base64", func(t *testing.T) { + invalidChunk := []string{"invalid-base64-data!!!"} + _, err := DecryptLargeStringWithEd25519(privKey, invalidChunk) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to decode base64")) + }) + + t.Run("decrypt with too short ciphertext", func(t *testing.T) { + // Create valid base64 but too short for ed25519 format + shortData := []string{"dGVzdA=="} // "test" in base64 + _, err := DecryptLargeStringWithEd25519(privKey, shortData) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("ciphertext too short")) + }) +} + +func TestKeyFormatting(t *testing.T) { + RegisterTestingT(t) + + t.Run("RSA private key formatting", func(t *testing.T) { + privKey, _, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + pemKey := MarshalRSAPrivateKey(privKey) + Expect(pemKey).To(ContainSubstring("-----BEGIN RSA PRIVATE KEY-----")) + Expect(pemKey).To(ContainSubstring("-----END RSA PRIVATE KEY-----")) + }) + + t.Run("RSA public key to SSH format", func(t *testing.T) { + _, pubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + sshKey, err := MarshalPublicKey(pubKey) + Expect(err).To(BeNil()) + Expect(string(sshKey)).To(HavePrefix("ssh-rsa ")) + }) + + t.Run("RSA public key to PEM format", func(t *testing.T) { + _, pubKey, err := GenerateKeyPair(2048) + Expect(err).To(BeNil()) + + pemKey, err := PublicKeyToBytes(pubKey) + Expect(err).To(BeNil()) + Expect(string(pemKey)).To(ContainSubstring("-----BEGIN RSA PUBLIC KEY-----")) + Expect(string(pemKey)).To(ContainSubstring("-----END RSA PUBLIC KEY-----")) + }) +} diff --git a/pkg/api/secrets/cryptor.go b/pkg/api/secrets/cryptor.go index 28eac4f1..7c51b8ce 100644 --- a/pkg/api/secrets/cryptor.go +++ b/pkg/api/secrets/cryptor.go @@ -13,6 +13,7 @@ const EncryptedSecretFilesDataFileName = "secrets.yaml" type Cryptor interface { GenerateKeyPairWithProfile(projectName, profile string) error + GenerateEd25519KeyPairWithProfile(projectName, profile string) error ReadProfileConfig() error AddFile(path string) error RemoveFile(path string) error diff --git a/pkg/api/secrets/cryptor_test.go b/pkg/api/secrets/cryptor_test.go index 5a8e24b2..62b6ffa5 100644 --- a/pkg/api/secrets/cryptor_test.go +++ b/pkg/api/secrets/cryptor_test.go @@ -173,6 +173,17 @@ func TestNewCryptor(t *testing.T) { }, wantErr: "git repo is not configured", }, + { + name: "happy path with ed25519 keys", + testExampleDir: "testdata/repo", + opts: []Option{ + withGitDir("gitdir"), + WithProfile("test-profile"), + WithGeneratedEd25519Keys("test-project", "test-profile"), + }, + prepareMocks: acceptAllChanges, + actions: happyPathEd25519Scenario, + }, } t.Parallel() for _, tt := range cases { @@ -360,3 +371,121 @@ func cloneWorkdir(c Cryptor, wd, pubKey, privKey string, m *mocks) (Cryptor, fun ) return anotherC, cleanup, err } + +func happyPathEd25519Scenario(t *testing.T, c Cryptor, m *mocks, wd string) { + oldSecretFile1Content, err := os.ReadFile("testdata/repo/stacks/common/secrets.yaml") + Expect(err).To(BeNil()) + oldSecretFile2Content, err := os.ReadFile("testdata/repo/stacks/refapp/secrets.yaml") + Expect(err).To(BeNil()) + commonSecretsFilePath := path.Join(wd, "stacks/common/secrets.yaml") + refappSecretsFilePath := path.Join(wd, "stacks/refapp/secrets.yaml") + + t.Run("add file", func(t *testing.T) { + Expect(c.AddFile("stacks/common/secrets.yaml")).To(BeNil()) + secrets := c.GetSecretFiles().Secrets + Expect(secrets).NotTo(BeNil()) + Expect(secrets).To(HaveKey(c.PublicKey())) + files := secrets[c.PublicKey()].Files + Expect(files).To(HaveLen(1)) + Expect(files[0].Path).To(Equal("stacks/common/secrets.yaml")) + Expect(files[0].EncryptedData).NotTo(BeEmpty()) + Expect(c.AddFile("stacks/refapp/secrets.yaml")).To(BeNil()) + }) + + gitIgnoreFile := path.Join(wd, ".gitignore") + t.Run("secrets added to gitignore", func(t *testing.T) { + Expect(gitIgnoreFile).To(BeAnExistingFile()) + gitignoreContent, err := os.ReadFile(gitIgnoreFile) + Expect(err).To(BeNil()) + Expect(string(gitignoreContent)).To(ContainSubstring("stacks/common/secrets.yaml")) + Expect(string(gitignoreContent)).To(ContainSubstring("stacks/refapp/secrets.yaml")) + }) + + t.Run("decrypt file", func(t *testing.T) { + Expect(os.RemoveAll(commonSecretsFilePath)).To(BeNil()) + Expect(c.DecryptAll(false)).To(BeNil()) + newSecretFileContent, err := os.ReadFile(commonSecretsFilePath) + Expect(err).To(BeNil()) + Expect(newSecretFileContent).To(Equal(oldSecretFile1Content)) + + newSecretFileContent, err = os.ReadFile(refappSecretsFilePath) + Expect(err).To(BeNil()) + Expect(newSecretFileContent).To(Equal(oldSecretFile2Content)) + }) + + // Test ed25519 key compatibility by generating another ed25519 key pair + anotherEd25519PrivKey, anotherEd25519PubKey, err := ciphers.GenerateEd25519KeyPair() + Expect(err).To(BeNil()) + anotherEd25519PubKeySSH, err := ciphers.MarshalEd25519PublicKey(anotherEd25519PubKey) + Expect(err).To(BeNil()) + anotherEd25519PrivKeyPEM, err := ciphers.MarshalEd25519PrivateKey(anotherEd25519PrivKey) + Expect(err).To(BeNil()) + + anotherEd25519PubKeyString := strings.TrimSpace(string(anotherEd25519PubKeySSH)) + + t.Run("allow another ed25519 key", func(t *testing.T) { + Expect(c.AddPublicKey(anotherEd25519PubKeyString)).To(BeNil()) + Expect(c.ReadSecretFiles()).To(BeNil()) + knownKeys := c.GetKnownPublicKeys() + Expect(knownKeys).To(ContainElement(c.PublicKey())) + Expect(knownKeys).To(ContainElement(anotherEd25519PubKeyString)) + }) + + // clone to another dir with ed25519 key + anotherC, cleanup, err := cloneWorkdir(c, wd, anotherEd25519PubKeyString, anotherEd25519PrivKeyPEM, m) + Expect(err).To(BeNil()) + defer cleanup() + + t.Run("decrypt secrets in another dir with ed25519", func(t *testing.T) { + Expect(anotherC.PrivateKey()).To(Equal(anotherEd25519PrivKeyPEM)) + Expect(anotherC.ReadSecretFiles()).To(BeNil()) + knownKeys := anotherC.GetKnownPublicKeys() + Expect(knownKeys).To(ContainElement(c.PublicKey())) + Expect(knownKeys).To(ContainElement(anotherEd25519PubKeyString)) + Expect(anotherC.DecryptAll(false)).To(BeNil()) + + newSecretFileContent, err := os.ReadFile(path.Join(anotherC.Workdir(), "stacks/common/secrets.yaml")) + Expect(err).To(BeNil()) + Expect(newSecretFileContent).To(Equal(oldSecretFile1Content)) + + newSecretFileContent, err = os.ReadFile(path.Join(anotherC.Workdir(), "stacks/refapp/secrets.yaml")) + Expect(err).To(BeNil()) + Expect(newSecretFileContent).To(Equal(oldSecretFile2Content)) + }) + + t.Run("verify ed25519 key format in config", func(t *testing.T) { + // Verify the generated ed25519 keys are properly formatted + Expect(c.PublicKey()).To(HavePrefix("ssh-ed25519 ")) + Expect(c.PrivateKey()).To(ContainSubstring("-----BEGIN PRIVATE KEY-----")) + Expect(c.PrivateKey()).To(ContainSubstring("-----END PRIVATE KEY-----")) + }) + + t.Run("do not re-encrypt if no changes", func(t *testing.T) { + prevEncrypted := c.GetSecretFiles().Secrets[c.PublicKey()].Files + Expect(c.EncryptChanged(false, false)).To(BeNil()) + newEncrypted := c.GetSecretFiles().Secrets[c.PublicKey()].Files + Expect(prevEncrypted).To(Equal(newEncrypted)) + }) + + t.Run("remove file", func(t *testing.T) { + Expect(c.RemoveFile("stacks/common/secrets.yaml")).To(BeNil()) + secrets := c.GetSecretFiles().Secrets + Expect(secrets).NotTo(BeNil()) + Expect(secrets).To(HaveKey(c.PublicKey())) + files := secrets[c.PublicKey()].Files + Expect(files).To(HaveLen(1)) + + Expect(gitIgnoreFile).To(BeAnExistingFile()) + gitignoreContent, err := os.ReadFile(path.Join(wd, ".gitignore")) + Expect(err).To(BeNil()) + Expect(string(gitignoreContent)).NotTo(ContainSubstring("stacks/common/secrets.yaml")) + }) + + t.Run("secrets removed from gitignore", func(t *testing.T) { + Expect(gitIgnoreFile).To(BeAnExistingFile()) + gitignoreContent, err := os.ReadFile(path.Join(wd, ".gitignore")) + Expect(err).To(BeNil()) + Expect(string(gitignoreContent)).NotTo(ContainSubstring("stacks/common/secrets.yaml")) + Expect(string(gitignoreContent)).To(ContainSubstring("stacks/refapp/secrets.yaml")) + }) +} diff --git a/pkg/api/secrets/management.go b/pkg/api/secrets/management.go index ee713250..69b2ebd5 100644 --- a/pkg/api/secrets/management.go +++ b/pkg/api/secrets/management.go @@ -9,6 +9,7 @@ import ( "path" "strings" + "golang.org/x/crypto/ed25519" "golang.org/x/crypto/ssh" "github.com/go-git/go-billy/v5" @@ -381,7 +382,6 @@ func (c *cryptor) decryptSecretData(encryptedData []string) ([]byte, error) { return nil, errors.New("private key is not configured") } - var key *rsa.PrivateKey var err error if _, err := ssh.ParseRawPrivateKey([]byte(c.currentPrivateKey)); errors.As(err, new(*ssh.PassphraseMissingError)) && c.privateKeyPassphrase == "" { @@ -408,15 +408,22 @@ func (c *cryptor) decryptSecretData(encryptedData []string) ([]byte, error) { return nil, errors.Wrapf(err, "failed to parse private key with passphrase (did you configure privateKeyPassword?)") } else if err != nil { return nil, errors.Wrapf(err, "failed to parse private key") - } else if castedKey, ok := rawKey.(*rsa.PrivateKey); !ok { - return nil, errors.Errorf("unsupported private key type: %T", rawKey) - } else { - key = castedKey } - decrypted, err := ciphers.DecryptLargeString(key, encryptedData) - if err != nil { - return nil, errors.Wrapf(err, "failed to decrypt secret") + var decrypted []byte + // Handle different key types + if rsaKey, ok := rawKey.(*rsa.PrivateKey); ok { + decrypted, err = ciphers.DecryptLargeString(rsaKey, encryptedData) + if err != nil { + return nil, errors.Wrapf(err, "failed to decrypt secret with RSA key") + } + } else if ed25519Key, ok := rawKey.(ed25519.PrivateKey); ok { + decrypted, err = ciphers.DecryptLargeStringWithEd25519(ed25519Key, encryptedData) + if err != nil { + return nil, errors.Wrapf(err, "failed to decrypt secret with ed25519 key") + } + } else { + return nil, errors.Errorf("unsupported private key type: %T", rawKey) } return decrypted, nil } @@ -540,6 +547,37 @@ func (c *cryptor) GenerateKeyPairWithProfile(projectName string, profile string) return nil } +func (c *cryptor) GenerateEd25519KeyPairWithProfile(projectName string, profile string) error { + c.profile = profile + privKey, pubKey, err := ciphers.GenerateEd25519KeyPair() + if err != nil { + return errors.Wrapf(err, "failed to generate ed25519 key pair") + } + + privKeyPem, err := ciphers.MarshalEd25519PrivateKey(privKey) + if err != nil { + return errors.Wrapf(err, "failed to marshal ed25519 private key") + } + c.currentPrivateKey = privKeyPem + + mPubKey, err := ciphers.MarshalEd25519PublicKey(pubKey) + if err != nil { + return errors.Wrapf(err, "failed to serialize ed25519 public key") + } + + c.currentPublicKey = TrimPubKey(string(mPubKey)) + + config := &api.ConfigFile{ + ProjectName: projectName, + PrivateKey: c.currentPrivateKey, + PublicKey: c.currentPublicKey, + } + if err := config.WriteConfigFile(c.workDir, c.profile); err != nil { + return errors.Wrapf(err, "failed to write config file") + } + return nil +} + func (c *cryptor) applyOpts(opts []Option) error { for _, opt := range opts { if err := opt.f(c); err != nil { diff --git a/pkg/api/secrets/opts.go b/pkg/api/secrets/opts.go index a4e73bb9..3b1d87ac 100644 --- a/pkg/api/secrets/opts.go +++ b/pkg/api/secrets/opts.go @@ -111,6 +111,16 @@ func WithGeneratedKeys(projectName, profile string) Option { } } +func WithGeneratedEd25519Keys(projectName, profile string) Option { + return Option{ + f: func(c *cryptor) error { + c.profile = profile + c.projectName = projectName + return c.GenerateEd25519KeyPairWithProfile(c.projectName, c.profile) + }, + } +} + func WithKeysFromScConfig(profile string) Option { return Option{ f: func(c *cryptor) error { diff --git a/pkg/api/secrets/util.go b/pkg/api/secrets/util.go index 844c4533..de0d9b60 100644 --- a/pkg/api/secrets/util.go +++ b/pkg/api/secrets/util.go @@ -4,13 +4,20 @@ import ( "strings" ) -// TODO: ignore key alias +// TrimPubKey normalizes SSH public keys by ignoring aliases/comments +// SSH keys have format: [optional-comment/alias] +// We only keep the key-type and key-data parts to ensure keys with different +// aliases but same key data are treated identically for encryption/decryption func TrimPubKey(pubKey string) string { - if parts := strings.Fields(pubKey); len(parts) > 3 || len(parts) < 2 { + parts := strings.Fields(strings.TrimSpace(pubKey)) + + // SSH public keys should have at least 2 parts: key-type and key-data + if len(parts) < 2 { return strings.TrimSpace(pubKey) - } else { - return strings.Join(parts, " ") } + + // Return only the first two parts (key-type and key-data), ignoring alias/comment + return strings.Join(parts[:2], " ") } func TrimPrivKey(privKey string) string { diff --git a/pkg/api/secrets/util_test.go b/pkg/api/secrets/util_test.go new file mode 100644 index 00000000..c6aef80a --- /dev/null +++ b/pkg/api/secrets/util_test.go @@ -0,0 +1,116 @@ +package secrets + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestTrimPubKey(t *testing.T) { + RegisterTestingT(t) + + t.Run("RSA key with alias should be trimmed", func(t *testing.T) { + keyWithAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA user@example.com" + keyWithoutAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA" + + result := TrimPubKey(keyWithAlias) + Expect(result).To(Equal(keyWithoutAlias)) + }) + + t.Run("ed25519 key with alias should be trimmed", func(t *testing.T) { + keyWithAlias := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKrlWq1Ly0vfk0S79H2f1hZJDB6jkUZvuyrx58bI+AaA user@host" + keyWithoutAlias := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKrlWq1Ly0vfk0S79H2f1hZJDB6jkUZvuyrx58bI+AaA" + + result := TrimPubKey(keyWithAlias) + Expect(result).To(Equal(keyWithoutAlias)) + }) + + t.Run("key without alias should remain unchanged", func(t *testing.T) { + keyWithoutAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA" + + result := TrimPubKey(keyWithoutAlias) + Expect(result).To(Equal(keyWithoutAlias)) + }) + + t.Run("key with multiple word alias should be trimmed", func(t *testing.T) { + keyWithMultiAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA user@example.com deployment key" + keyWithoutAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA" + + result := TrimPubKey(keyWithMultiAlias) + Expect(result).To(Equal(keyWithoutAlias)) + }) + + t.Run("keys with same data but different aliases should normalize identically", func(t *testing.T) { + keyWithAlias1 := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA user1@host1" + keyWithAlias2 := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA user2@host2" + keyWithAlias3 := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA different-alias" + + result1 := TrimPubKey(keyWithAlias1) + result2 := TrimPubKey(keyWithAlias2) + result3 := TrimPubKey(keyWithAlias3) + + // All should normalize to the same result + Expect(result1).To(Equal(result2)) + Expect(result2).To(Equal(result3)) + Expect(result1).To(Equal("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA")) + }) + + t.Run("key with leading/trailing whitespace should be trimmed", func(t *testing.T) { + keyWithSpaces := " ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA user@example.com " + keyWithoutAlias := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA" + + result := TrimPubKey(keyWithSpaces) + Expect(result).To(Equal(keyWithoutAlias)) + }) + + t.Run("malformed key with only one part should return as-is", func(t *testing.T) { + malformedKey := "invalid-key-data" + + result := TrimPubKey(malformedKey) + Expect(result).To(Equal("invalid-key-data")) + }) + + t.Run("empty key should return empty", func(t *testing.T) { + emptyKey := "" + + result := TrimPubKey(emptyKey) + Expect(result).To(Equal("")) + }) + + t.Run("key with only whitespace should return empty", func(t *testing.T) { + whitespaceKey := " \t\n " + + result := TrimPubKey(whitespaceKey) + Expect(result).To(Equal("")) + }) + + t.Run("different key types should be handled correctly", func(t *testing.T) { + dsaKey := "ssh-dss AAAAB3NzaC1kc3MAAACBAKyE user@example.com" + ecdsaKey := "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI user@example.com" + + dsaResult := TrimPubKey(dsaKey) + ecdsaResult := TrimPubKey(ecdsaKey) + + Expect(dsaResult).To(Equal("ssh-dss AAAAB3NzaC1kc3MAAACBAKyE")) + Expect(ecdsaResult).To(Equal("ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI")) + }) +} + +func TestTrimPrivKey(t *testing.T) { + RegisterTestingT(t) + + t.Run("private key with whitespace should be trimmed", func(t *testing.T) { + privKeyWithSpaces := " -----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ...\n-----END PRIVATE KEY----- " + expectedKey := "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ...\n-----END PRIVATE KEY-----" + + result := TrimPrivKey(privKeyWithSpaces) + Expect(result).To(Equal(expectedKey)) + }) + + t.Run("private key without whitespace should remain unchanged", func(t *testing.T) { + privKey := "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ...\n-----END PRIVATE KEY-----" + + result := TrimPrivKey(privKey) + Expect(result).To(Equal(privKey)) + }) +}