Skip to content

Commit d678c09

Browse files
fix: reject low-order X25519 public keys (#418)
Return `Error::InvalidData` when an X25519 exchange is not contributory (low-order peer public key) in `mix_key_exchange` and in asymmetric encryption/decryption, including online ciphertexts.
1 parent 57feaa8 commit d678c09

8 files changed

Lines changed: 153 additions & 10 deletions

File tree

‎.github/workflows/other/webassembly-build/action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ runs:
77
shell: bash
88
run: |
99
rustup update
10-
cargo install wasm-pack
10+
cargo install wasm-pack --locked
1111
1212
- uses: actions/setup-node@v4
1313
with:

‎.github/workflows/tests/webassembly/safari/action.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ runs:
99
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable
1010
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
1111
source $HOME/.cargo/env
12-
cargo install wasm-pack
12+
cargo install wasm-pack --locked
1313
1414
- name: Unit tests
1515
working-directory: ./

‎src/ciphertext/ciphertext_v2.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ impl CiphertextV2Asymmetric {
191191

192192
let key = ephemeral_private_key.diffie_hellman(&public_key);
193193

194+
if !key.was_contributory() {
195+
return Err(Error::InvalidData);
196+
}
197+
194198
let ciphertext = CiphertextV2Symmetric::encrypt(data, key.as_bytes(), aad, header)?;
195199

196200
Ok(Self {
@@ -209,6 +213,10 @@ impl CiphertextV2Asymmetric {
209213

210214
let key = private_key.diffie_hellman(&self.public_key);
211215

216+
if !key.was_contributory() {
217+
return Err(Error::InvalidData);
218+
}
219+
212220
self.ciphertext.decrypt(key.as_bytes(), aad, header)
213221
}
214222
}

‎src/key/key_v1.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,15 @@ pub fn generate_keypair() -> KeyV1Pair {
108108
}
109109
}
110110

111-
pub fn mix_key_exchange(private: &KeyV1Private, public: &KeyV1Public) -> Vec<u8> {
112-
private.key.diffie_hellman(&public.key).as_bytes().to_vec()
111+
pub fn mix_key_exchange(private: &KeyV1Private, public: &KeyV1Public) -> Result<Vec<u8>> {
112+
let shared = private.key.diffie_hellman(&public.key);
113+
114+
// Reject low-order public keys, which yield an all-zero shared secret
115+
if !shared.was_contributory() {
116+
return Err(Error::InvalidData);
117+
}
118+
119+
Ok(shared.as_bytes().to_vec())
113120
}
114121

115122
impl From<&KeyV1Public> for x25519_dalek::PublicKey {

‎src/key/mod.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,11 @@ pub fn generate_keypair(version: KeyVersion) -> KeyPair {
210210
/// assert_eq!(bob_shared, alice_shared);
211211
/// ```
212212
pub fn mix_key_exchange(private_key: &PrivateKey, public_key: &PublicKey) -> Result<Vec<u8>> {
213-
Ok(match (&private_key.payload, &public_key.payload) {
213+
match (&private_key.payload, &public_key.payload) {
214214
(PrivateKeyPayload::V1(private_key), PublicKeyPayload::V1(public_key)) => {
215215
key_v1::mix_key_exchange(private_key, public_key)
216216
} //_ => Err(DevoCryptoError::InvalidDataType),
217-
})
217+
}
218218
}
219219

220220
fn keypair_headers(version: KeyVersion) -> (Header<PrivateKey>, Header<PublicKey>) {

‎src/online_ciphertext/mod.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ impl OnlineCiphertextHeader {
128128
match self.payload {
129129
OnlineCiphertextHeaderPayload::V1(header) => match header {
130130
OnlineCiphertextV1Header::Asymmetric(header) => {
131-
let cipher = OnlineCiphertextV1Decryptor::new_asymmetric(key, full_aad, header);
131+
let cipher =
132+
OnlineCiphertextV1Decryptor::new_asymmetric(key, full_aad, header)?;
132133

133134
Ok(OnlineCiphertextDecryptor::V1(cipher))
134135
}

‎src/online_ciphertext/online_ciphertext_v1.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ impl OnlineCiphertextV1Encryptor {
334334

335335
let key = ephemeral_private_key.diffie_hellman(&public_key);
336336

337+
if !key.was_contributory() {
338+
return Err(Error::InvalidData);
339+
}
340+
337341
// Generate a new nonce
338342
let mut nonce = [0u8; 20];
339343
rand::rngs::SysRng
@@ -389,12 +393,16 @@ impl OnlineCiphertextV1Decryptor {
389393
private_key: &PrivateKey,
390394
mut aad: Vec<u8>,
391395
header: OnlineCiphertextV1HeaderAsymmetric,
392-
) -> Self {
396+
) -> Result<Self> {
393397
// Perform a ECDH exchange as per ECIES
394398
let private_key = x25519_dalek::StaticSecret::from(private_key);
395399

396400
let key = private_key.diffie_hellman(&header.public_key);
397401

402+
if !key.was_contributory() {
403+
return Err(Error::InvalidData);
404+
}
405+
398406
// Derive the key
399407
let key = Zeroizing::new(blake3::derive_key(CONTEXT, key.as_bytes()));
400408
let cipher = XChaCha20Poly1305::new_from_slice(key.as_ref())
@@ -406,11 +414,11 @@ impl OnlineCiphertextV1Decryptor {
406414
let mut header_bytes: Vec<u8> = header.borrow().into();
407415
aad.append(&mut header_bytes);
408416

409-
Self {
417+
Ok(Self {
410418
header: OnlineCiphertextV1Header::Asymmetric(header),
411419
aad,
412420
cipher,
413-
}
421+
})
414422
}
415423
}
416424

‎tests/low_order_points.rs‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use devolutions_crypto::Error;
2+
use devolutions_crypto::ciphertext::{Ciphertext, CiphertextVersion, encrypt_asymmetric};
3+
use devolutions_crypto::key::{KeyVersion, PublicKey, generate_keypair, mix_key_exchange};
4+
use devolutions_crypto::online_ciphertext::{
5+
OnlineCiphertextEncryptor, OnlineCiphertextHeader, OnlineCiphertextVersion,
6+
};
7+
8+
use std::convert::TryFrom;
9+
10+
// Curve25519 points of small order, including non-canonical encodings
11+
const LOW_ORDER_POINTS: [[u8; 32]; 7] = [
12+
hex("0000000000000000000000000000000000000000000000000000000000000000"),
13+
hex("0100000000000000000000000000000000000000000000000000000000000000"),
14+
hex("e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
15+
hex("5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
16+
hex("ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
17+
hex("edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
18+
hex("eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
19+
];
20+
21+
const fn hex(s: &str) -> [u8; 32] {
22+
const fn nibble(c: u8) -> u8 {
23+
match c {
24+
b'0'..=b'9' => c - b'0',
25+
b'a'..=b'f' => c - b'a' + 10,
26+
_ => panic!("invalid hex"),
27+
}
28+
}
29+
30+
let s = s.as_bytes();
31+
let mut out = [0u8; 32];
32+
let mut i = 0;
33+
while i < 32 {
34+
out[i] = (nibble(s[i * 2]) << 4) | nibble(s[i * 2 + 1]);
35+
i += 1;
36+
}
37+
out
38+
}
39+
40+
fn with_last_32(bytes: &[u8], point: &[u8; 32]) -> Vec<u8> {
41+
let mut bytes = bytes.to_vec();
42+
let offset = bytes.len() - 32;
43+
bytes[offset..].copy_from_slice(point);
44+
bytes
45+
}
46+
47+
fn low_order_public_key(point: &[u8; 32]) -> PublicKey {
48+
let template: Vec<u8> = generate_keypair(KeyVersion::Latest).public_key.into();
49+
PublicKey::try_from(with_last_32(&template, point).as_slice()).unwrap()
50+
}
51+
52+
#[test]
53+
fn mix_key_exchange_rejects_low_order_points() {
54+
let keypair = generate_keypair(KeyVersion::Latest);
55+
56+
for point in &LOW_ORDER_POINTS {
57+
let public_key = low_order_public_key(point);
58+
let result = mix_key_exchange(&keypair.private_key, &public_key);
59+
assert!(matches!(result, Err(Error::InvalidData)));
60+
}
61+
}
62+
63+
#[test]
64+
fn encrypt_asymmetric_rejects_low_order_points() {
65+
for point in &LOW_ORDER_POINTS {
66+
let public_key = low_order_public_key(point);
67+
let result = encrypt_asymmetric(b"data", &public_key, CiphertextVersion::Latest);
68+
assert!(matches!(result, Err(Error::InvalidData)));
69+
}
70+
}
71+
72+
#[test]
73+
fn decrypt_asymmetric_rejects_low_order_points() {
74+
let keypair = generate_keypair(KeyVersion::Latest);
75+
let ciphertext: Vec<u8> =
76+
encrypt_asymmetric(b"data", &keypair.public_key, CiphertextVersion::Latest)
77+
.unwrap()
78+
.into();
79+
80+
for point in &LOW_ORDER_POINTS {
81+
// The ephemeral public key follows the 8-byte header
82+
let mut forged = ciphertext.clone();
83+
forged[8..40].copy_from_slice(point);
84+
85+
let forged = Ciphertext::try_from(forged.as_slice()).unwrap();
86+
let result = forged.decrypt_asymmetric(&keypair.private_key);
87+
assert!(matches!(result, Err(Error::InvalidData)));
88+
}
89+
}
90+
91+
#[test]
92+
fn online_ciphertext_rejects_low_order_points() {
93+
let keypair = generate_keypair(KeyVersion::Latest);
94+
let encryptor = OnlineCiphertextEncryptor::new_asymmetric(
95+
&keypair.public_key,
96+
b"",
97+
16,
98+
OnlineCiphertextVersion::Latest,
99+
)
100+
.unwrap();
101+
let header: Vec<u8> = (&encryptor.get_header()).into();
102+
103+
for point in &LOW_ORDER_POINTS {
104+
let public_key = low_order_public_key(point);
105+
let result = OnlineCiphertextEncryptor::new_asymmetric(
106+
&public_key,
107+
b"",
108+
16,
109+
OnlineCiphertextVersion::Latest,
110+
);
111+
assert!(matches!(result, Err(Error::InvalidData)));
112+
113+
// The ephemeral public key is the last field of the header
114+
let forged =
115+
OnlineCiphertextHeader::try_from(with_last_32(&header, point).as_slice()).unwrap();
116+
let result = forged.into_decryptor_asymmetric(&keypair.private_key, b"");
117+
assert!(matches!(result, Err(Error::InvalidData)));
118+
}
119+
}

0 commit comments

Comments
 (0)