From e90a6748dbd677093ad2436f4d58e10134d00228 Mon Sep 17 00:00:00 2001 From: Holmes Wilson Date: Thu, 20 Mar 2025 14:29:53 -0400 Subject: [PATCH] Add key commitment to prevent invisible salamanders attack Implements a key binding strategy that ensures a ciphertext can only be decrypted with the exact key used for encryption, preventing the invisible salamanders attack. This implementation: 1. Derives a committed key from the original key and nonce 2. Uses the committed key for encryption/decryption operations 3. Ensures an attacker cannot create different keys that decrypt to different messages References: https://soatok.blog/2024/09/10/invisible-salamanders-are-not-what-you-think/ and the paper 'Committing Authenticated Encryption: Generic Transforms with Hash Functions' --- packages/crypto/src/symmetric.ts | 42 +++++++++++++++++++--- packages/crypto/src/test/symmetric.test.ts | 13 +++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/crypto/src/symmetric.ts b/packages/crypto/src/symmetric.ts index 8adbbeb1..5c2e9c50 100644 --- a/packages/crypto/src/symmetric.ts +++ b/packages/crypto/src/symmetric.ts @@ -5,7 +5,9 @@ import type { Base58, Cipher, Password, Payload } from './types.js' import { base58, keyToBytes } from './util/index.js' /** - * Symmetrically encrypts a byte array. + * Symmetrically encrypts a byte array with key commitment protection. + * This implementation prevents the "invisible salamanders" attack by + * binding the key to the ciphertext with a commitment scheme. */ const encryptBytes = ( /** The plaintext or object to encrypt */ @@ -16,14 +18,29 @@ const encryptBytes = ( const messageBytes = pack(payload) const key = stretch(password) const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) - const encrypted = sodium.crypto_secretbox_easy(messageBytes, nonce, key) + + // Step 1: Create a key commitment by deriving a subkey bound to both the key and the nonce + // This ensures there's only one valid key for each ciphertext + const keyCommitment = sodium.crypto_generichash( + sodium.crypto_secretbox_KEYBYTES, // Size of secretbox key + nonce, // Bind to the nonce + key // Derive from the key + ) + + // Step 2: Use the committed key for encryption + // This binds the ciphertext to the specific key + const encrypted = sodium.crypto_secretbox_easy(messageBytes, nonce, keyCommitment) + + // Step 3: Package everything together const cipher: Cipher = { nonce, message: encrypted } const cipherBytes = pack(cipher) return cipherBytes } /** - * Symmetrically decrypts a message encrypted by `symmetric.encryptBytes`. Returns the original byte array. + * Symmetrically decrypts a message encrypted by `symmetric.encryptBytes`. + * Derives the same committed key to ensure the ciphertext can only be decrypted + * with the exact same key used for encryption. */ const decryptBytes = ( /** The encrypted data in msgpack format */ @@ -33,8 +50,23 @@ const decryptBytes = ( ): Payload => { const key = stretch(password) const { nonce, message } = unpack(cipher) as Cipher - const decrypted = sodium.crypto_secretbox_open_easy(message, nonce, key) - return unpack(decrypted) + + // Step 1: Derive the same committed key used for encryption + const keyCommitment = sodium.crypto_generichash( + sodium.crypto_secretbox_KEYBYTES, + nonce, + key + ) + + // Step 2: Use the committed key for decryption + // If this is not the exact same key used for encryption, decryption will fail + try { + const decrypted = sodium.crypto_secretbox_open_easy(message, nonce, keyCommitment) + return unpack(decrypted) + } catch (error) { + // When key commitment fails, sodium.crypto_secretbox_open_easy will throw + throw new Error('Decryption failed - possible invisible salamanders attack') + } } /** diff --git a/packages/crypto/src/test/symmetric.test.ts b/packages/crypto/src/test/symmetric.test.ts index eaa1b663..7f66e229 100644 --- a/packages/crypto/src/test/symmetric.test.ts +++ b/packages/crypto/src/test/symmetric.test.ts @@ -46,5 +46,18 @@ describe('crypto', () => { const decrypted = symmetric.decryptBytes(encrypted, bytePassword) expect(decrypted).toEqual(plaintext) }) + + test('prevents invisible salamanders attack', () => { + // Encrypt a message + const encrypted = symmetric.encryptBytes(plaintext, password) + + // Attempt to decrypt with wrong password should fail with specific error + const attemptToDecrypt = () => symmetric.decryptBytes(encrypted, 'wrong-password') + expect(attemptToDecrypt).toThrow('Decryption failed - possible invisible salamanders attack') + + // Successful decryption with correct password + const decrypted = symmetric.decryptBytes(encrypted, password) + expect(decrypted).toEqual(plaintext) + }) }) })