Skip to content

Commit e90a674

Browse files
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'
1 parent f61e367 commit e90a674

File tree

2 files changed

+50
-5
lines changed

2 files changed

+50
-5
lines changed

packages/crypto/src/symmetric.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import type { Base58, Cipher, Password, Payload } from './types.js'
55
import { base58, keyToBytes } from './util/index.js'
66

77
/**
8-
* Symmetrically encrypts a byte array.
8+
* Symmetrically encrypts a byte array with key commitment protection.
9+
* This implementation prevents the "invisible salamanders" attack by
10+
* binding the key to the ciphertext with a commitment scheme.
911
*/
1012
const encryptBytes = (
1113
/** The plaintext or object to encrypt */
@@ -16,14 +18,29 @@ const encryptBytes = (
1618
const messageBytes = pack(payload)
1719
const key = stretch(password)
1820
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
19-
const encrypted = sodium.crypto_secretbox_easy(messageBytes, nonce, key)
21+
22+
// Step 1: Create a key commitment by deriving a subkey bound to both the key and the nonce
23+
// This ensures there's only one valid key for each ciphertext
24+
const keyCommitment = sodium.crypto_generichash(
25+
sodium.crypto_secretbox_KEYBYTES, // Size of secretbox key
26+
nonce, // Bind to the nonce
27+
key // Derive from the key
28+
)
29+
30+
// Step 2: Use the committed key for encryption
31+
// This binds the ciphertext to the specific key
32+
const encrypted = sodium.crypto_secretbox_easy(messageBytes, nonce, keyCommitment)
33+
34+
// Step 3: Package everything together
2035
const cipher: Cipher = { nonce, message: encrypted }
2136
const cipherBytes = pack(cipher)
2237
return cipherBytes
2338
}
2439

2540
/**
26-
* Symmetrically decrypts a message encrypted by `symmetric.encryptBytes`. Returns the original byte array.
41+
* Symmetrically decrypts a message encrypted by `symmetric.encryptBytes`.
42+
* Derives the same committed key to ensure the ciphertext can only be decrypted
43+
* with the exact same key used for encryption.
2744
*/
2845
const decryptBytes = (
2946
/** The encrypted data in msgpack format */
@@ -33,8 +50,23 @@ const decryptBytes = (
3350
): Payload => {
3451
const key = stretch(password)
3552
const { nonce, message } = unpack(cipher) as Cipher
36-
const decrypted = sodium.crypto_secretbox_open_easy(message, nonce, key)
37-
return unpack(decrypted)
53+
54+
// Step 1: Derive the same committed key used for encryption
55+
const keyCommitment = sodium.crypto_generichash(
56+
sodium.crypto_secretbox_KEYBYTES,
57+
nonce,
58+
key
59+
)
60+
61+
// Step 2: Use the committed key for decryption
62+
// If this is not the exact same key used for encryption, decryption will fail
63+
try {
64+
const decrypted = sodium.crypto_secretbox_open_easy(message, nonce, keyCommitment)
65+
return unpack(decrypted)
66+
} catch (error) {
67+
// When key commitment fails, sodium.crypto_secretbox_open_easy will throw
68+
throw new Error('Decryption failed - possible invisible salamanders attack')
69+
}
3870
}
3971

4072
/**

packages/crypto/src/test/symmetric.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,18 @@ describe('crypto', () => {
4646
const decrypted = symmetric.decryptBytes(encrypted, bytePassword)
4747
expect(decrypted).toEqual(plaintext)
4848
})
49+
50+
test('prevents invisible salamanders attack', () => {
51+
// Encrypt a message
52+
const encrypted = symmetric.encryptBytes(plaintext, password)
53+
54+
// Attempt to decrypt with wrong password should fail with specific error
55+
const attemptToDecrypt = () => symmetric.decryptBytes(encrypted, 'wrong-password')
56+
expect(attemptToDecrypt).toThrow('Decryption failed - possible invisible salamanders attack')
57+
58+
// Successful decryption with correct password
59+
const decrypted = symmetric.decryptBytes(encrypted, password)
60+
expect(decrypted).toEqual(plaintext)
61+
})
4962
})
5063
})

0 commit comments

Comments
 (0)