-
Notifications
You must be signed in to change notification settings - Fork 179
Security/Fix: Resolve critical security vulnerabilities and core module edge cases #4609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,19 +9,48 @@ import ( | |
| io "io" | ||
|
|
||
| "github.com/pkg/errors" | ||
| "golang.org/x/crypto/pbkdf2" | ||
| ) | ||
|
|
||
| // EncryptAES256GCMBase64 encrypts the given string plaintext using AES-256-GCM with the given password and returns the base64-encoded ciphertext. | ||
| const ( | ||
| // pbkdf2Iterations is the number of PBKDF2 iterations used for key derivation. | ||
| // OWASP 2023 recommends 600000 for PBKDF2-HMAC-SHA256. | ||
| pbkdf2Iterations = 600000 | ||
| // pbkdf2KeyLen is the AES-256 key length in bytes. | ||
| pbkdf2KeyLen = 32 | ||
| ) | ||
|
|
||
| // getAESKey derives a 32-byte AES key from the given password using PBKDF2. | ||
| // This replaces the previous SHA-256-based derivation which was vulnerable to | ||
| // offline brute-force attacks. | ||
| func getAESKey(password string) []byte { | ||
| return getAESKeyPBKDF2(password) | ||
| } | ||
|
|
||
| // getAESKeyPBKDF2 derives a key using PBKDF2-HMAC-SHA256 with a static salt. | ||
| func getAESKeyPBKDF2(password string) []byte { | ||
| salt := []byte("zeta-chain-aes-256-gcm") | ||
| return pbkdf2.Key([]byte(password), salt, pbkdf2Iterations, pbkdf2KeyLen, sha256.New) | ||
| } | ||
|
Comment on lines
+31
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A salt's primary job in PBKDF2 is to ensure that two users with identical passwords still derive different keys, and to make precomputed (rainbow-table) attacks against the specific application salt economically infeasible. Using the hardcoded constant Prompt To Fix With AIThis is a comment left during a code review.
Path: pkg/crypto/aes256_gcm.go
Line: 31-34
Comment:
**Static PBKDF2 salt breaks per-credential isolation**
A salt's primary job in PBKDF2 is to ensure that two users with identical passwords still derive different keys, and to make precomputed (rainbow-table) attacks against the specific application salt economically infeasible. Using the hardcoded constant `"zeta-chain-aes-256-gcm"` means every encrypted relayer key in every deployment shares the same key-derivation context. An attacker who breaks one node's password gets a derivation target that applies to all deployments — the 600 k iterations slow them down but the single-salt surface remains fixed. The salt should be generated fresh per-encryption (e.g., `crypto/rand`, 16+ bytes) and prepended to the ciphertext alongside the nonce so `Decrypt` can recover it.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| // getAESKeyLegacy derives a key using a single SHA-256 round (old insecure method). | ||
| // Retained for backward compatibility with encrypted data from before the PBKDF2 migration. | ||
| func getAESKeyLegacy(key string) []byte { | ||
| h := sha256.New() | ||
| h.Write([]byte(key)) | ||
| return h.Sum(nil) | ||
| } | ||
|
|
||
| // EncryptAES256GCMBase64 encrypts the given string plaintext using AES-256-GCM with the given password | ||
| // and returns the base64-encoded ciphertext. | ||
| func EncryptAES256GCMBase64(plaintext string, password string) (string, error) { | ||
| // validate the input | ||
| if plaintext == "" { | ||
| return "", errors.New("plaintext must not be empty") | ||
| } | ||
| if password == "" { | ||
| return "", errors.New("password must not be empty") | ||
| } | ||
|
|
||
| // encrypt the plaintext | ||
| ciphertext, err := EncryptAES256GCM([]byte(plaintext), password) | ||
| if err != nil { | ||
| return "", errors.Wrap(err, "failed to encrypt string plaintext") | ||
|
|
@@ -31,21 +60,18 @@ func EncryptAES256GCMBase64(plaintext string, password string) (string, error) { | |
|
|
||
| // DecryptAES256GCMBase64 decrypts the given base64-encoded ciphertext using AES-256-GCM with the given password. | ||
| func DecryptAES256GCMBase64(ciphertextBase64 string, password string) (string, error) { | ||
| // validate the input | ||
| if ciphertextBase64 == "" { | ||
| return "", errors.New("ciphertext must not be empty") | ||
| } | ||
| if password == "" { | ||
| return "", errors.New("password must not be empty") | ||
| } | ||
|
|
||
| // decode the base64-encoded ciphertext | ||
| ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) | ||
| if err != nil { | ||
| return "", errors.Wrap(err, "failed to decode base64 ciphertext") | ||
| } | ||
|
|
||
| // decrypt the ciphertext | ||
| plaintext, err := DecryptAES256GCM(ciphertext, password) | ||
| if err != nil { | ||
| return "", errors.Wrap(err, "failed to decrypt ciphertext") | ||
|
|
@@ -55,54 +81,59 @@ func DecryptAES256GCMBase64(ciphertextBase64 string, password string) (string, e | |
|
|
||
| // EncryptAES256GCM encrypts the given plaintext using AES-256-GCM with the given password. | ||
| func EncryptAES256GCM(plaintext []byte, password string) ([]byte, error) { | ||
| // create AES cipher | ||
| block, err := aes.NewCipher(getAESKey(password)) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // create GCM mode | ||
| gcm, err := cipher.NewGCM(block) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // generate random nonce | ||
| nonce := make([]byte, gcm.NonceSize()) | ||
| if _, err := io.ReadFull(rand.Reader, nonce); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // encrypt the plaintext | ||
| ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) | ||
|
|
||
| return ciphertext, nil | ||
| } | ||
|
|
||
| // DecryptAES256GCM decrypts the given ciphertext using AES-256-GCM with the given password. | ||
| // It first tries PBKDF2 key derivation, then falls back to legacy SHA-256 for backward compatibility. | ||
| func DecryptAES256GCM(ciphertext []byte, password string) ([]byte, error) { | ||
| // create AES cipher | ||
| block, err := aes.NewCipher(getAESKey(password)) | ||
| // try PBKDF2 key derivation first | ||
| plaintext, err := decryptWithKey(ciphertext, getAESKeyPBKDF2(password)) | ||
| if err != nil { | ||
| // fall back to legacy SHA-256 key derivation | ||
| plaintext, err = decryptWithKey(ciphertext, getAESKeyLegacy(password)) | ||
| if err != nil { | ||
| return nil, errors.Wrap(err, "failed to decrypt with both PBKDF2 and legacy key derivation") | ||
| } | ||
| } | ||
| return plaintext, nil | ||
| } | ||
|
|
||
| // decryptWithKey decrypts ciphertext with the given AES key. | ||
| func decryptWithKey(ciphertext []byte, key []byte) ([]byte, error) { | ||
| block, err := aes.NewCipher(key) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // create GCM mode | ||
| gcm, err := cipher.NewGCM(block) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // get the nonce size | ||
| nonceSize := gcm.NonceSize() | ||
| if len(ciphertext) < nonceSize { | ||
| return nil, errors.New("ciphertext too short") | ||
| } | ||
|
|
||
| // extract the nonce from the ciphertext | ||
| nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] | ||
|
|
||
| // decrypt the ciphertext | ||
| // #nosec G407 false positive https://github.com/securego/gosec/issues/1211 | ||
| plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) | ||
| if err != nil { | ||
|
|
@@ -111,11 +142,3 @@ func DecryptAES256GCM(ciphertext []byte, password string) ([]byte, error) { | |
|
|
||
| return plaintext, nil | ||
| } | ||
|
|
||
| // getAESKey uses SHA-256 to create a 32-byte key for AES encryption. | ||
| func getAESKey(key string) []byte { | ||
| h := sha256.New() | ||
| h.Write([]byte(key)) | ||
|
|
||
| return h.Sum(nil) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ package types | |
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/pkg/errors" | ||
| ) | ||
|
|
||
| func (m InboundParams) Validate() error { | ||
|
|
@@ -13,26 +15,21 @@ func (m InboundParams) Validate() error { | |
| return fmt.Errorf("amount cannot be nil") | ||
| } | ||
|
|
||
| // Disabled checks | ||
| // TODO: Improve the checks, move the validation call to a new place and reenable | ||
| // https://github.com/zeta-chain/node/issues/2234 | ||
| // https://github.com/zeta-chain/node/issues/2235 | ||
| //if err := ValidateAddressForChain(m.Sender, m.SenderChainId) err != nil { | ||
| // return err | ||
| //} | ||
| //if m.TxOrigin != "" { | ||
| // errTxOrigin := ValidateAddressForChain(m.TxOrigin, m.SenderChainId) | ||
| // if errTxOrigin != nil { | ||
| // return errTxOrigin | ||
| // } | ||
| //} | ||
| //if err := ValidateHashForChain(m.ObservedHash, m.SenderChainId); err != nil { | ||
| // return errors.Wrap(err, "invalid inbound tx observed hash") | ||
| //} | ||
| //if m.BallotIndex != "" { | ||
| // if err := ValidateCCTXIndex(m.BallotIndex); err != nil { | ||
| // return errors.Wrap(err, "invalid inbound tx ballot index") | ||
| // } | ||
| //} | ||
| if err := ValidateAddressForChain(m.Sender, m.SenderChainId); err != nil { | ||
| return err | ||
| } | ||
| if m.TxOrigin != "" { | ||
| if err := ValidateAddressForChain(m.TxOrigin, m.SenderChainId); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| if err := ValidateHashForChain(m.ObservedHash, m.SenderChainId); err != nil { | ||
| return errors.Wrap(err, "invalid inbound tx observed hash") | ||
|
Comment on lines
+26
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Tighten Ethereum/Zeta hash validation to a fixed hash length. Line 26 now relies on 🤖 Prompt for AI Agents |
||
| } | ||
| if m.BallotIndex != "" { | ||
| if err := ValidateCCTXIndex(m.BallotIndex); err != nil { | ||
| return errors.Wrap(err, "invalid inbound tx ballot index") | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: zeta-chain/node
Length of output: 8851
Use a per-ciphertext salt for PBKDF2.
The fixed salt makes every password derive the same AES key, so the KDF no longer provides per-message uniqueness. Store a random salt with each ciphertext and include version/KDF metadata so legacy payloads can still be decrypted. Also defer PBKDF2 until after the ciphertext length check to avoid unnecessary work on obviously invalid input.
🤖 Prompt for AI Agents