Consider replacing AES-256-CTR with AES-256-GCM
EncryptionService uses AES-256-CTR with an IV deterministically derived from the salt (MD5(salt)). Two secrets encrypted under the same key+salt yield the XOR of plaintexts, enabling secret recovery with DB read access. Accepted post-V2 migration.
Description
The EncryptionService uses AES-256-CTR mode with an initialization vector (IV) derived deterministically from the salt parameter via MD5(salt).
const encryptionMethod = "aes-256-ctr"; // L6, CTR mode requires unique IV per encryptionpublic encryptString(secretValue: string, salt: string | null, keyName: keyof EncryptionKeys): string {const saltBuffer = this.getHashedSaltBuffer(salt ?? keyName);// L24, IV derived from salt; if null, falls back to keyName stringconst key = this.encryptionKey[keyName];const cipher = createCipheriv(encryptionMethod, key, saltBuffer);// L27, deterministic IV reused across calls with same salt+keyreturn Buffer.concat([cipher.update(secretValue), cipher.final()]).toString("hex");}private getHashedSaltBuffer(salt: string): Buffer {return createHash("md5").update(salt).digest();// L33, MD5 of salt = 16 bytes = AES-CTR IV. Same input always yields same IV.}
The call site at partner.service.ts#L156-L165 encrypts two different secrets (passphrase and secret) for the same partner with the identical salt (partnerId) and key (polymarketBuilderEncryptionKey):
polymarketBuilderApiPassphraseEncrypted: this.encryptionService.encryptString(builder.passphrase,partnerId, // L158, salt = partnerId"polymarketBuilderEncryptionKey", // L159, same key name),polymarketBuilderApiSecretEncrypted: this.encryptionService.encryptString(builder.secret,partnerId, // L163, salt = partnerId (identical to above)"polymarketBuilderEncryptionKey", // L164, same key name (identical to above)),
Vulnerable scenario:
- Both
builder.passphraseandbuilder.secretare encrypted under the same AES key with IV =MD5(partnerId). - AES-CTR with a reused key+IV pair means
ciphertext_A XOR ciphertext_B = plaintext_A XOR plaintext_B. - An attacker with potential database read access (SQL injection, backup leak, compromised cloud IAM) XORs the two ciphertexts.
- If one plaintext is known or partially guessable, the other is fully recoverable.
- This pattern extends to every
EncryptionKeysmember used with the same salt, faker private keys, oracle signing keys, Solana authority keys, trading bot keys, etc.
Impact
While the attack complexity is evidently considerable, a potential attacker with database read access can decrypt all secrets without needing the encryption keys themselves.
Recommendation
Replace AES-256-CTR with AES-256-GCM (authenticated encryption). Generate a cryptographically random 96-bit IV per encryption operation and prepend it to the ciphertext. Never derive IVs deterministically from predictable inputs.
Resolution
Accepted by team, the specific reuse pattern is gone after the Polymarket V2 migration removed per-partner builder credentials, and remaining EncryptionService consumers each use a distinct keyName with a single secret per (salt, keyName) pair.

