Skip to content

Commit 04bcb6e

Browse files
committed
feat: add X25519 ratchet helpers and implement ratchet-aware decryption
1 parent f21c4ed commit 04bcb6e

File tree

2 files changed

+244
-19
lines changed

2 files changed

+244
-19
lines changed

src/identity.rs

Lines changed: 235 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,48 @@ pub const DERIVED_KEY_LENGTH: usize = 256 / 8;
2020
#[cfg(not(feature = "fernet-aes128"))]
2121
pub const DERIVED_KEY_LENGTH: usize = 512 / 8;
2222

23+
pub const RATCHET_ID_LENGTH: usize = 10;
24+
25+
pub type RatchetId = [u8; RATCHET_ID_LENGTH];
26+
27+
pub fn ratchet_pub_from_priv(priv_key_bytes: &[u8]) -> Result<[u8; PUBLIC_KEY_LENGTH], RnsError> {
28+
if priv_key_bytes.len() != PUBLIC_KEY_LENGTH {
29+
return Err(RnsError::InvalidArgument);
30+
}
31+
32+
let mut secret_bytes = [0u8; PUBLIC_KEY_LENGTH];
33+
secret_bytes.copy_from_slice(&priv_key_bytes[..PUBLIC_KEY_LENGTH]);
34+
let secret = StaticSecret::from(secret_bytes);
35+
36+
Ok(ratchet_pub_from_secret(&secret))
37+
}
38+
39+
pub fn ratchet_id_from_pub(pub_key_bytes: &[u8]) -> Result<RatchetId, RnsError> {
40+
if pub_key_bytes.len() != PUBLIC_KEY_LENGTH {
41+
return Err(RnsError::InvalidArgument);
42+
}
43+
44+
let mut pub_bytes = [0u8; PUBLIC_KEY_LENGTH];
45+
pub_bytes.copy_from_slice(&pub_key_bytes[..PUBLIC_KEY_LENGTH]);
46+
Ok(ratchet_id_from_pub_bytes(&pub_bytes))
47+
}
48+
49+
fn ratchet_pub_from_secret(secret: &StaticSecret) -> [u8; PUBLIC_KEY_LENGTH] {
50+
PublicKey::from(secret).to_bytes()
51+
}
52+
53+
fn ratchet_id_from_pub_bytes(pub_bytes: &[u8; PUBLIC_KEY_LENGTH]) -> RatchetId {
54+
let digest = Sha256::new().chain_update(pub_bytes).finalize();
55+
let mut id = [0u8; RATCHET_ID_LENGTH];
56+
id.copy_from_slice(&digest[..RATCHET_ID_LENGTH]);
57+
id
58+
}
59+
60+
fn ratchet_id_from_secret(secret: &StaticSecret) -> RatchetId {
61+
let pub_bytes = ratchet_pub_from_secret(secret);
62+
ratchet_id_from_pub_bytes(&pub_bytes)
63+
}
64+
2365
pub trait EncryptIdentity {
2466
fn encrypt<'a, R: CryptoRngCore + Copy>(
2567
&self,
@@ -333,6 +375,61 @@ impl PrivateIdentity {
333375
pub fn derive_key(&self, public_key: &PublicKey, salt: Option<&[u8]>) -> DerivedKey {
334376
DerivedKey::new_from_private_key(&self.private_key, public_key, salt)
335377
}
378+
379+
pub fn decrypt_token<'a, R: CryptoRngCore + Copy>(
380+
&self,
381+
rng: R,
382+
ciphertext_token: &[u8],
383+
salt: Option<&[u8]>,
384+
ratchets: &[StaticSecret],
385+
enforce_ratchets: bool,
386+
ratchet_id: &mut Option<RatchetId>,
387+
out_buf: &'a mut [u8],
388+
) -> Result<&'a [u8], RnsError> {
389+
*ratchet_id = None;
390+
391+
if ciphertext_token.len() <= PUBLIC_KEY_LENGTH {
392+
return Err(RnsError::InvalidArgument);
393+
}
394+
395+
let (peer_pub_bytes, ciphertext) = ciphertext_token.split_at(PUBLIC_KEY_LENGTH);
396+
397+
let mut peer_pub_arr = [0u8; PUBLIC_KEY_LENGTH];
398+
peer_pub_arr.copy_from_slice(peer_pub_bytes);
399+
let peer_pub = PublicKey::from(peer_pub_arr);
400+
401+
let default_salt = self.address_hash().as_slice();
402+
let salt_slice = salt.unwrap_or(default_salt);
403+
404+
for ratchet in ratchets {
405+
let shared = ratchet.diffie_hellman(&peer_pub);
406+
let derived = DerivedKey::new(&shared, Some(salt_slice));
407+
match self.decrypt(rng, ciphertext, &derived, out_buf) {
408+
Ok(plain_text) => {
409+
let len = plain_text.len();
410+
*ratchet_id = Some(ratchet_id_from_secret(ratchet));
411+
return Ok(&out_buf[..len]);
412+
}
413+
Err(RnsError::IncorrectSignature) | Err(RnsError::CryptoError) => {
414+
continue;
415+
}
416+
Err(err) => return Err(err),
417+
}
418+
}
419+
420+
if enforce_ratchets {
421+
return Err(RnsError::CryptoError);
422+
}
423+
424+
let derived = self.derive_key(&peer_pub, Some(salt_slice));
425+
match self.decrypt(rng, ciphertext, &derived, out_buf) {
426+
Ok(plain_text) => {
427+
let len = plain_text.len();
428+
Ok(&out_buf[..len])
429+
}
430+
Err(err) => Err(err),
431+
}
432+
}
336433
}
337434

338435
impl HashIdentity for PrivateIdentity {
@@ -455,8 +552,15 @@ impl DerivedKey {
455552
#[cfg(test)]
456553
mod tests {
457554
use rand_core::{CryptoRng, OsRng, RngCore};
555+
use sha2::{Digest, Sha256};
556+
use x25519_dalek::{PublicKey, StaticSecret};
557+
558+
use crate::error::RnsError;
458559

459-
use super::{EncryptIdentity, PrivateIdentity, PUBLIC_KEY_LENGTH};
560+
use super::{
561+
ratchet_id_from_pub, ratchet_pub_from_priv, DerivedKey, EncryptIdentity, PrivateIdentity,
562+
PUBLIC_KEY_LENGTH, RATCHET_ID_LENGTH,
563+
};
460564

461565
#[test]
462566
fn private_identity_hex_string() {
@@ -523,6 +627,136 @@ mod tests {
523627
86, 44, 75, 95, 158, 43, 180, 113, 78, 129, 131, 76, 103,
524628
];
525629

630+
#[test]
631+
fn ratchet_helper_roundtrip() {
632+
let secret = StaticSecret::random_from_rng(TestRng::new(0x0102030405060708));
633+
let secret_bytes = secret.to_bytes();
634+
let pub_from_helper =
635+
ratchet_pub_from_priv(&secret_bytes[..]).expect("ratchet public from private");
636+
let expected_pub = PublicKey::from(&secret).to_bytes();
637+
assert_eq!(pub_from_helper, expected_pub);
638+
639+
let ratchet_id = ratchet_id_from_pub(&expected_pub[..]).expect("ratchet id");
640+
let digest = Sha256::new().chain_update(expected_pub).finalize();
641+
assert_eq!(&ratchet_id[..], &digest[..RATCHET_ID_LENGTH]);
642+
}
643+
644+
#[test]
645+
fn decrypt_token_prefers_ratchet_keys() {
646+
let recipient = PrivateIdentity::new_from_name("ratchet-pref");
647+
let ratchet_secret = StaticSecret::random_from_rng(TestRng::new(0x1122334455667788));
648+
let ratchet_public = PublicKey::from(&ratchet_secret);
649+
let ratchet_pub_bytes = ratchet_public.to_bytes();
650+
let expected_id = ratchet_id_from_pub(&ratchet_pub_bytes[..]).expect("ratchet id");
651+
652+
let derived = DerivedKey::new_from_ephemeral_key(
653+
TestRng::new(0x0f1e2d3c4b5a6978),
654+
&ratchet_public,
655+
Some(recipient.address_hash().as_slice()),
656+
);
657+
658+
let mut cipher_buf = [0u8; 512];
659+
let cipher = recipient
660+
.as_identity()
661+
.encrypt(
662+
TestRng::new(0x8877665544332211),
663+
b"ratchet-msg",
664+
&derived,
665+
&mut cipher_buf,
666+
)
667+
.expect("cipher");
668+
669+
let mut plain_buf = [0u8; 512];
670+
let mut ratchet_id = None;
671+
let ratchets = vec![ratchet_secret];
672+
let plaintext = recipient
673+
.decrypt_token(
674+
TestRng::new(0x13579bdf2468ace0),
675+
cipher,
676+
None,
677+
ratchets.as_slice(),
678+
false,
679+
&mut ratchet_id,
680+
&mut plain_buf,
681+
)
682+
.expect("ratchet decrypt");
683+
684+
assert_eq!(plaintext, b"ratchet-msg");
685+
assert_eq!(ratchet_id, Some(expected_id));
686+
}
687+
688+
#[test]
689+
fn decrypt_token_enforces_ratchets() {
690+
let recipient = PrivateIdentity::new_from_name("ratchet-enforce");
691+
let derived = recipient
692+
.as_identity()
693+
.derive_key(TestRng::new(0x1122aabbccddeeff));
694+
695+
let mut cipher_buf = [0u8; 256];
696+
let cipher = recipient
697+
.as_identity()
698+
.encrypt(
699+
TestRng::new(0xffeeddccbbaa2211),
700+
b"enforce",
701+
&derived,
702+
&mut cipher_buf,
703+
)
704+
.expect("cipher");
705+
706+
let mut plain_buf = [0u8; 256];
707+
let mut ratchet_id = None;
708+
let result = recipient.decrypt_token(
709+
TestRng::new(0x0101010101010101),
710+
cipher,
711+
None,
712+
&[],
713+
true,
714+
&mut ratchet_id,
715+
&mut plain_buf,
716+
);
717+
718+
assert!(matches!(result, Err(RnsError::CryptoError)));
719+
assert!(ratchet_id.is_none());
720+
}
721+
722+
#[test]
723+
fn decrypt_token_falls_back_without_enforcement() {
724+
let recipient = PrivateIdentity::new_from_name("ratchet-fallback");
725+
let derived = recipient
726+
.as_identity()
727+
.derive_key(TestRng::new(0x99aabbccddeeff00));
728+
729+
let mut cipher_buf = [0u8; 256];
730+
let cipher = recipient
731+
.as_identity()
732+
.encrypt(
733+
TestRng::new(0x0011223344556677),
734+
b"fallback",
735+
&derived,
736+
&mut cipher_buf,
737+
)
738+
.expect("cipher");
739+
740+
let mut plain_buf = [0u8; 256];
741+
let mut ratchet_id = Some([0u8; RATCHET_ID_LENGTH]);
742+
let random_ratchet = StaticSecret::random_from_rng(TestRng::new(0xabcdef0123456789));
743+
let ratchets = vec![random_ratchet];
744+
let plaintext = recipient
745+
.decrypt_token(
746+
TestRng::new(0x89abcdef01234567),
747+
cipher,
748+
None,
749+
ratchets.as_slice(),
750+
false,
751+
&mut ratchet_id,
752+
&mut plain_buf,
753+
)
754+
.expect("fallback decrypt");
755+
756+
assert_eq!(plaintext, b"fallback");
757+
assert!(ratchet_id.is_none());
758+
}
759+
526760
#[derive(Clone, Copy)]
527761
struct TestRng {
528762
state: u128,

tests/interop_identity.rs

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::{env, path::PathBuf, process::Command};
22

33
use rand_core::OsRng;
44
use reticulum::identity::{DecryptIdentity, EncryptIdentity, Identity, PrivateIdentity, PUBLIC_KEY_LENGTH};
5-
use x25519_dalek::PublicKey;
65

76
fn manifest_dir() -> &'static str {
87
env!("CARGO_MANIFEST_DIR")
@@ -112,23 +111,15 @@ fn rust_encrypt_hex(pt: &[u8], recipient_pub_hex: &str) -> String {
112111
}
113112

114113
fn rust_decrypt_hex(ct_hex: &str, recipient_priv_hex: &str) -> Vec<u8> {
115-
let mut cipher = hex::decode(ct_hex).expect("ciphertext hex");
116-
assert!(cipher.len() > PUBLIC_KEY_LENGTH, "ciphertext too short");
117-
118-
let token = cipher.split_off(PUBLIC_KEY_LENGTH);
119-
let ephemeral_bytes: [u8; PUBLIC_KEY_LENGTH] = cipher[..PUBLIC_KEY_LENGTH]
120-
.try_into()
121-
.expect("header length");
122-
let ephemeral_pub = PublicKey::from(ephemeral_bytes);
123-
114+
let cipher = hex::decode(ct_hex).expect("ciphertext hex");
124115
let recipient =
125116
PrivateIdentity::new_from_hex_string(recipient_priv_hex).expect("invalid recipient hex");
126-
let salt = recipient.address_hash().as_slice();
127-
let derived = recipient.derive_key(&ephemeral_pub, Some(salt));
128-
129-
let mut out_buf = vec![0u8; token.len()];
130-
recipient
131-
.decrypt(OsRng, &token, &derived, &mut out_buf)
132-
.expect("decrypt")
133-
.to_vec()
117+
let mut out_buf = vec![0u8; cipher.len()];
118+
let mut ratchet_id = None;
119+
let plaintext = recipient
120+
.decrypt_token(OsRng, &cipher, None, &[], false, &mut ratchet_id, &mut out_buf)
121+
.expect("decrypt");
122+
123+
assert!(ratchet_id.is_none());
124+
plaintext.to_vec()
134125
}

0 commit comments

Comments
 (0)