···11+[package]
22+name = "sachy-crypto"
33+authors.workspace = true
44+edition.workspace = true
55+repository.workspace = true
66+license.workspace = true
77+version.workspace = true
88+rust-version.workspace = true
99+1010+[dependencies]
1111+chacha20poly1305 = { version = "=0.11.0-rc.3", default-features = false, features = ["getrandom", "alloc"] }
1212+k256 = { version = "=0.14.0-rc.8", default-features = false, features = ["ecdh", "getrandom"] }
1313+sha2 = { version = "=0.11.0-rc.5", default-features = false, features = [] }
1414+dhkem = { version = "0.1.0-rc.0", features = ["getrandom", "k256"] }
1515+elliptic-curve = { version = "0.14.0-rc.28", default-features = false, features = ["ecdh"] }
+5
sachy-crypto/README.md
···11+# Sachy's Crypto
22+33+A custom rolled encryption scheme that more or less implements HPKE.
44+55+☢️ **WARNING: DO NOT USE IN PRODUCTION. THIS CRATE IS FOR LEARNING/PERSONAL USAGE. AAAAAAAAAA** ☢️
+392
sachy-crypto/src/lib.rs
···11+#![no_std]
22+33+use core::ops::AddAssign;
44+55+use chacha20poly1305::{
66+ AeadCore, AeadInOut, ChaCha20Poly1305, KeyInit,
77+ aead::{self, Buffer, array::Array, common::array::typenum::Unsigned},
88+};
99+use dhkem::{
1010+ Encapsulate, Generate, Kem, Secp256k1DecapsulationKey, Secp256k1EncapsulationKey, Secp256k1Kem,
1111+ TryDecapsulate,
1212+ kem::{Ciphertext, SharedKey},
1313+};
1414+use elliptic_curve::sec1::{FromSec1Point, ToSec1Point};
1515+use k256::{Sec1Point, ecdh::SharedSecret, elliptic_curve::subtle::ConstantTimeEq};
1616+1717+extern crate alloc;
1818+1919+/// Error type.
2020+///
2121+/// This type is deliberately opaque as to avoid potential side-channel
2222+/// leakage (e.g. padding oracle).
2323+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2424+pub struct ProtoError;
2525+2626+impl core::fmt::Display for ProtoError {
2727+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2828+ f.write_str("ProtoError")
2929+ }
3030+}
3131+3232+impl core::error::Error for ProtoError {}
3333+3434+impl From<chacha20poly1305::Error> for ProtoError {
3535+ fn from(_value: chacha20poly1305::Error) -> Self {
3636+ Self
3737+ }
3838+}
3939+4040+pub struct ClientHandshake(Secp256k1DecapsulationKey);
4141+4242+pub struct EncapsulatedPublicKey(Secp256k1EncapsulationKey);
4343+4444+impl EncapsulatedPublicKey {
4545+ pub fn serialize(&self) -> Sec1Point {
4646+ self.0.to_sec1_point(true)
4747+ }
4848+4949+ pub fn deserialize(buf: &[u8]) -> Result<Self, ProtoError> {
5050+ Ok(Self(
5151+ Secp256k1EncapsulationKey::from_sec1_bytes(buf).map_err(|_| ProtoError)?,
5252+ ))
5353+ }
5454+5555+ pub fn encapsulate(&self) -> (Ciphertext<Secp256k1Kem>, SharedKey<Secp256k1Kem>) {
5656+ self.0.encapsulate()
5757+ }
5858+}
5959+6060+impl ClientHandshake {
6161+ pub fn send() -> (EncapsulatedPublicKey, Self) {
6262+ let (decap, encap) = Secp256k1Kem::generate_keypair();
6363+6464+ (EncapsulatedPublicKey(encap), Self(decap))
6565+ }
6666+6767+ pub fn finish(self, ciphertext: &[u8], psk: &[u8; 32]) -> Result<TransportState, ProtoError> {
6868+ let shared = self
6969+ .0
7070+ .try_decapsulate_slice(ciphertext)
7171+ .map_err(|_| ProtoError)?;
7272+7373+ TransportState::init(psk, shared)
7474+ }
7575+}
7676+7777+pub struct ServerHandshake(SharedKey<Secp256k1Kem>);
7878+7979+impl ServerHandshake {
8080+ pub fn receive(buf: &[u8]) -> Result<(Ciphertext<Secp256k1Kem>, Self), ProtoError> {
8181+ let encap = EncapsulatedPublicKey::deserialize(buf)?;
8282+8383+ let (ciphertext, sk) = encap.encapsulate();
8484+8585+ Ok((ciphertext, Self(sk)))
8686+ }
8787+8888+ pub fn finish(self, psk: &[u8; 32]) -> Result<TransportState, ProtoError> {
8989+ TransportState::init(psk, self.0)
9090+ }
9191+}
9292+9393+/// Low-level Transport implementation.
9494+///
9595+/// This trait provides a particular "flavor" of transport, as there are
9696+/// different ways the specifics of the construction can be implemented.
9797+pub trait TransportPrimitive<A>
9898+where
9999+ A: AeadInOut,
100100+{
101101+ /// Type used as the Trasnport counter.
102102+ type Counter: AddAssign + Copy + Default + Eq;
103103+104104+ /// Value to use when incrementing the Transport counter (i.e. one)
105105+ const COUNTER_INCR: Self::Counter;
106106+107107+ /// Encrypt an AEAD message in-place at the given position in the Transport.
108108+ fn encrypt_in_place(
109109+ &self,
110110+ nonce: &aead::Nonce<A>,
111111+ associated_data: &[u8],
112112+ buffer: &mut dyn Buffer,
113113+ ) -> Result<(), ProtoError>;
114114+115115+ /// Decrypt an AEAD message in-place at the given position in the Transport.
116116+ fn decrypt_in_place(
117117+ &self,
118118+ nonce: &aead::Nonce<A>,
119119+ associated_data: &[u8],
120120+ buffer: &mut dyn Buffer,
121121+ ) -> Result<(), ProtoError>;
122122+}
123123+124124+pub struct SendingState<'a> {
125125+ transport: &'a TransportState,
126126+ counter: u64,
127127+}
128128+129129+impl SendingState<'_> {
130130+ pub fn encrypt(&mut self, msg: &mut alloc::vec::Vec<u8>) -> Result<(), ProtoError> {
131131+ let counter = self.counter.to_be_bytes();
132132+133133+ // Nonce is randomised to act as a OTP when mixed with the counter state
134134+ let mut epstein = Array::generate();
135135+136136+ self.transport.encrypt_in_place(&epstein, &counter, msg)?;
137137+138138+ msg.extend(Self::mix_nonce(&mut epstein, &counter));
139139+140140+ self.counter = self.counter.wrapping_add(TransportState::COUNTER_INCR);
141141+142142+ // If we wrapped around and equal the finish value, we have maxed out the amount of
143143+ // messages we can send.
144144+ if self.counter.ct_eq(&self.transport.finish).into() {
145145+ Err(ProtoError)
146146+ } else {
147147+ Ok(())
148148+ }
149149+ }
150150+151151+ fn mix_nonce<'a>(
152152+ epstein: &'a mut aead::Nonce<ChaCha20Poly1305>,
153153+ position: &'a [u8; 8],
154154+ ) -> &'a aead::Nonce<ChaCha20Poly1305> {
155155+ epstein[..position.len()]
156156+ .iter_mut()
157157+ .zip(position)
158158+ .for_each(|(byte, count)| *byte ^= *count);
159159+160160+ epstein
161161+ }
162162+}
163163+164164+pub struct ReceivingState<'a> {
165165+ transport: &'a TransportState,
166166+ counter: u64,
167167+}
168168+169169+impl ReceivingState<'_> {
170170+ pub fn decrypt(&mut self, msg: &mut alloc::vec::Vec<u8>) -> Result<(), ProtoError> {
171171+ let counter = self.counter.to_be_bytes();
172172+173173+ // Extract the nonce from the payload as this does not need to be decrypted
174174+ let epstein = Self::extract_nonce(&counter, msg)?;
175175+176176+ self.transport.decrypt_in_place(&epstein, &counter, msg)?;
177177+178178+ self.counter = self.counter.wrapping_add(TransportState::COUNTER_INCR);
179179+180180+ // If we wrapped around and equal the finish value, we have maxed out the amount of
181181+ // messages we can send.
182182+ if self.counter.ct_eq(&self.transport.finish).into() {
183183+ Err(ProtoError)
184184+ } else {
185185+ Ok(())
186186+ }
187187+ }
188188+189189+ fn extract_nonce(
190190+ position: &[u8; 8],
191191+ msg: &mut alloc::vec::Vec<u8>,
192192+ ) -> Result<aead::Nonce<ChaCha20Poly1305>, ProtoError> {
193193+ let index = msg
194194+ .len()
195195+ .checked_sub(<ChaCha20Poly1305 as AeadCore>::NonceSize::to_usize())
196196+ .ok_or(ProtoError)?;
197197+198198+ let mut epstein = Array::try_from(&msg[index..]).map_err(|_| ProtoError)?;
199199+200200+ epstein[..position.len()]
201201+ .iter_mut()
202202+ .zip(position)
203203+ .for_each(|(keyed, count)| {
204204+ *keyed ^= *count;
205205+ });
206206+207207+ msg.truncate(index);
208208+209209+ Ok(epstein)
210210+ }
211211+}
212212+213213+impl TransportPrimitive<ChaCha20Poly1305> for TransportState {
214214+ type Counter = u64;
215215+216216+ const COUNTER_INCR: Self::Counter = 1;
217217+218218+ fn encrypt_in_place(
219219+ &self,
220220+ epstein: &aead::Nonce<ChaCha20Poly1305>,
221221+ associated_data: &[u8],
222222+ buffer: &mut dyn Buffer,
223223+ ) -> Result<(), ProtoError> {
224224+ self.aead
225225+ .encrypt_in_place(epstein, associated_data, buffer)?;
226226+ Ok(())
227227+ }
228228+229229+ fn decrypt_in_place(
230230+ &self,
231231+ epstein: &aead::Nonce<ChaCha20Poly1305>,
232232+ associated_data: &[u8],
233233+ buffer: &mut dyn Buffer,
234234+ ) -> Result<(), ProtoError> {
235235+ self.aead
236236+ .decrypt_in_place(epstein, associated_data, buffer)?;
237237+ Ok(())
238238+ }
239239+}
240240+241241+pub struct TransportState {
242242+ aead: ChaCha20Poly1305,
243243+ finish: u64,
244244+}
245245+246246+impl TransportState {
247247+ pub fn init(psk: &[u8; 32], shared: impl Into<SharedSecret>) -> Result<Self, ProtoError> {
248248+ let noncer = shared.into();
249249+ let kdf = noncer.extract::<sha2::Sha256>(Some(psk));
250250+251251+ let mut key = [0u8; 32];
252252+253253+ let mut finish = [0u8; 8];
254254+255255+ kdf.expand(b"SachY-Crypt0", &mut key)
256256+ .map_err(|_| ProtoError)?;
257257+258258+ kdf.expand(b"PickANumber", &mut finish)
259259+ .map_err(|_| ProtoError)?;
260260+261261+ Ok(Self {
262262+ aead: ChaCha20Poly1305::new(&key.into()),
263263+ finish: u64::from_le_bytes(finish),
264264+ })
265265+ }
266266+267267+ pub fn split(&self) -> (SendingState<'_>, ReceivingState<'_>) {
268268+ let counter = self.finish;
269269+270270+ (
271271+ SendingState {
272272+ transport: self,
273273+ counter,
274274+ },
275275+ ReceivingState {
276276+ transport: self,
277277+ counter,
278278+ },
279279+ )
280280+ }
281281+}
282282+283283+#[cfg(test)]
284284+mod tests {
285285+ use alloc::vec;
286286+287287+ use super::*;
288288+289289+ #[test]
290290+ fn handshake_protocol_works() -> Result<(), ProtoError> {
291291+ let psk: [u8; 32] = [
292292+ 31, 48, 29, 177, 88, 236, 186, 84, 65, 51, 214, 243, 174, 24, 45, 101, 229, 129, 62,
293293+ 132, 45, 174, 183, 65, 89, 73, 107, 177, 77, 90, 164, 251,
294294+ ];
295295+296296+ let (ek, client) = ClientHandshake::send();
297297+298298+ // Pretend to send ek across the webz: client -> server
299299+ let (ciphertext, server) = ServerHandshake::receive(ek.serialize().as_bytes())?;
300300+301301+ // Pretend to send ciphertext across the webz: server -> client
302302+ let client_transport = client.finish(&ciphertext, &psk)?;
303303+ let server_transport = server.finish(&psk)?;
304304+305305+ let nonce = aead::Nonce::<ChaCha20Poly1305>::generate();
306306+307307+ let mut buffer1 = vec![0u8; 64];
308308+ let mut buffer2 = vec![0u8; 64];
309309+310310+ // Using the same nonce to check that the internal states match. Normally, client/server
311311+ // would work with randomised nonces, because nonce reuse is BAD
312312+ client_transport
313313+ .aead
314314+ .encrypt_in_place(&nonce, &[], &mut buffer1)?;
315315+ server_transport
316316+ .aead
317317+ .encrypt_in_place(&nonce, &[], &mut buffer2)?;
318318+319319+ // If the nonces match, then we can assume the rest of the internal state is the same too
320320+ // so the outputs should match each other
321321+ assert_eq!(&buffer1, &buffer2);
322322+323323+ Ok(())
324324+ }
325325+326326+ #[test]
327327+ fn two_way_transport_sync_works() -> Result<(), ProtoError> {
328328+ let shared_secret = [
329329+ 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d,
330330+ 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
331331+ 0x9c, 0x9d, 0x9e, 0x9f,
332332+ ];
333333+334334+ let psk: [u8; 32] = [
335335+ 31, 48, 29, 177, 88, 236, 186, 84, 65, 51, 214, 243, 174, 24, 45, 101, 229, 129, 62,
336336+ 132, 45, 174, 183, 65, 89, 73, 107, 177, 77, 90, 164, 251,
337337+ ];
338338+339339+ let alice = TransportState::init(&psk, Array(shared_secret))?;
340340+ let bob = TransportState::init(&psk, Array(shared_secret))?;
341341+342342+ let (mut alice_send, mut alice_recv) = alice.split();
343343+ let (mut bob_send, mut bob_recv) = bob.split();
344344+345345+ // Have to be synchronised on both ends, so the counter state matches between the two
346346+ // and thus messages can be encrypted/decrypted statefully. But the actual number is
347347+ // "random", making it harder to guess the position state.
348348+ assert_eq!(alice.finish, bob.finish);
349349+350350+ let orig = b"Test Message, Please ignore.";
351351+352352+ let mut msg = orig.to_vec();
353353+354354+ // a -> b
355355+ alice_send.encrypt(&mut msg)?;
356356+357357+ assert_ne!(orig.as_slice(), msg.as_slice());
358358+ let ct1 = msg.clone();
359359+360360+ bob_recv.decrypt(&mut msg)?;
361361+362362+ // a -> b
363363+ alice_send.encrypt(&mut msg)?;
364364+365365+ assert_ne!(msg.as_slice(), ct1.as_slice());
366366+ let ct2 = msg.clone();
367367+368368+ bob_recv.decrypt(&mut msg)?;
369369+370370+ // b -> a
371371+ bob_send.encrypt(&mut msg)?;
372372+373373+ // None of the ciphertexts should match each other
374374+ assert_ne!(msg.as_slice(), ct1.as_slice());
375375+ assert_ne!(msg.as_slice(), ct2.as_slice());
376376+ assert_ne!(ct1.as_slice(), ct2.as_slice());
377377+378378+ alice_recv.decrypt(&mut msg)?;
379379+380380+ assert_eq!(orig.as_slice(), msg.as_slice());
381381+382382+ // Counters are tracked from sender to receiver
383383+ assert_eq!(alice_send.counter, bob_recv.counter);
384384+ assert_eq!(bob_send.counter, alice_recv.counter);
385385+386386+ // Counters are not linked on the same side
387387+ assert_ne!(alice_send.counter, alice_recv.counter);
388388+ assert_ne!(bob_send.counter, bob_recv.counter);
389389+390390+ Ok(())
391391+ }
392392+}