oxedyne/fe2o3/fe2o3_crypto/src/agree.rs
12.5 KiB, 1 run
created by r1870400018:35396, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! X25519 key agreement: encapsulating a session key to a named recipient. |
| 2 | //! |
| 3 | //! This is the classical half of [`oxedyne_fe2o3_iop_crypto::kem::KeyExchanger`], |
| 4 | //! beside the post-quantum [`crate::kem`], and it is deliberately not shaped on |
| 5 | //! it: that module's `encap` ignores the public key it is given and encapsulates |
| 6 | //! to the key the scheme itself holds, which is the opposite of what a caller |
| 7 | //! wrapping a secret for somebody else needs. |
| 8 | //! |
| 9 | //! # What it costs to build |
| 10 | //! |
| 11 | //! Nothing new in the dependency graph. `curve25519-dalek` is already there |
| 12 | //! through `ed25519-dalek`, and [`MontgomeryPoint::mul_clamped`] is public and |
| 13 | //! unfeatured, so the whole of X25519 is those two calls and a digest. |
| 14 | //! |
| 15 | //! # Where it is exercised |
| 16 | //! |
| 17 | //! In `ore_store`, not here. This crate's test target has not linked since some |
| 18 | //! time before 12026-08-22 -- `rust-lld` reports `jent_entropy_collector_alloc` |
| 19 | //! and three siblings undefined, jitter entropy symbols from the `pqcrypto` |
| 20 | //! C build -- so a test written beside this code could not be run. That is a |
| 21 | //! separate defect on the post-quantum path; the consequence here is only that |
| 22 | //! the tests live at the first downstream caller that links. |
| 23 | |
| 24 | use crate::keys::Keys; |
| 25 | |
| 26 | use oxedyne_fe2o3_core::prelude::*; |
| 27 | use oxedyne_fe2o3_hash::hash::HashScheme; |
| 28 | use oxedyne_fe2o3_iop_crypto::{ |
| 29 | kem::KeyExchanger, |
| 30 | keys::KeyManager, |
| 31 | }; |
| 32 | use oxedyne_fe2o3_iop_hash::api::{ |
| 33 | Hasher, |
| 34 | HashForm, |
| 35 | }; |
| 36 | use oxedyne_fe2o3_namex::id::{ |
| 37 | InNamex, |
| 38 | LocalId, |
| 39 | NamexId, |
| 40 | }; |
| 41 | |
| 42 | use std::{ |
| 43 | convert::TryFrom, |
| 44 | fmt, |
| 45 | str, |
| 46 | }; |
| 47 | |
| 48 | use curve25519_dalek::montgomery::MontgomeryPoint; |
| 49 | use rand_core::{ |
| 50 | OsRng, |
| 51 | RngCore, |
| 52 | }; |
| 53 | use secrecy::{ |
| 54 | ExposeSecret, |
| 55 | Secret, |
| 56 | }; |
| 57 | |
| 58 | |
| 59 | #[derive(Clone)] |
| 60 | pub enum AgreementScheme { |
| 61 | X25519(Keys< |
| 62 | {Self::X25519_PK_LEN}, |
| 63 | {Self::X25519_SK_LEN}, |
| 64 | >), |
| 65 | } |
| 66 | |
| 67 | impl fmt::Display for AgreementScheme { |
| 68 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 69 | write!(f, "{:?}", self) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | impl fmt::Debug for AgreementScheme { |
| 74 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 75 | match self { |
| 76 | Self::X25519(..) => write!(f, "X25519"), |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | impl InNamex for AgreementScheme { |
| 82 | |
| 83 | fn name_id(&self) -> Outcome<NamexId> { |
| 84 | Ok(match self { |
| 85 | Self::X25519(..) => |
| 86 | res!(NamexId::try_from("HFN5dPSwWFeAktUWQEr0S9Zn1LyMSaurR4tPSPM9c0w=")), |
| 87 | }) |
| 88 | } |
| 89 | |
| 90 | /// Version-dependent identifier for the agreement scheme, which is a far more |
| 91 | /// compact alternative to the 256 bit Namex id. |
| 92 | fn local_id(&self) -> LocalId { |
| 93 | match self { |
| 94 | Self::X25519(..) => LocalId(1), |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | fn assoc_names_base64( |
| 99 | gname: &'static str, |
| 100 | ) |
| 101 | -> Outcome<Option<Vec<( |
| 102 | &'static str, |
| 103 | &'static str, |
| 104 | )>>> |
| 105 | { |
| 106 | let ids = match gname { |
| 107 | "schemes" => [ |
| 108 | ("X25519", "HFN5dPSwWFeAktUWQEr0S9Zn1LyMSaurR4tPSPM9c0w="), |
| 109 | ], |
| 110 | _ => return Err(err!( |
| 111 | "The Namex group name '{}' is not recognised for AgreementScheme.", gname; |
| 112 | Invalid, Input)), |
| 113 | }; |
| 114 | Ok(if ids.len() == 0 { |
| 115 | None |
| 116 | } else { |
| 117 | Some(ids.to_vec()) |
| 118 | }) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | impl KeyManager for AgreementScheme { |
| 123 | |
| 124 | fn clone_with_keys(&self, pk: Option<&[u8]>, sk: Option<&[u8]>) -> Outcome<Self> { |
| 125 | Ok(match self { |
| 126 | Self::X25519(..) => Self::X25519(Keys { |
| 127 | pk: match pk { |
| 128 | Some(pk) => Some(res!(<[u8; Self::X25519_PK_LEN]>::try_from(&pk[..]))), |
| 129 | None => None, |
| 130 | }, |
| 131 | sks: match sk { |
| 132 | Some(sk) => Some(Secret::new(res!( |
| 133 | <[u8; Self::X25519_SK_LEN]>::try_from(&sk[..]) |
| 134 | ))), |
| 135 | None => None, |
| 136 | }, |
| 137 | }), |
| 138 | }) |
| 139 | } |
| 140 | |
| 141 | fn get_public_key(&self) -> Outcome<Option<&[u8]>> { |
| 142 | Ok(match self { |
| 143 | Self::X25519(keys) => match &keys.pk { |
| 144 | Some(k) => Some(&k[..]), |
| 145 | None => None, |
| 146 | }, |
| 147 | }) |
| 148 | } |
| 149 | |
| 150 | fn get_secret_key(&self) -> Outcome<Option<&[u8]>> { |
| 151 | Ok(match self { |
| 152 | Self::X25519(keys) => match &keys.sks { |
| 153 | Some(sks) => { |
| 154 | let sk = sks.expose_secret(); |
| 155 | Some(&sk[..]) |
| 156 | }, |
| 157 | None => None, |
| 158 | }, |
| 159 | }) |
| 160 | } |
| 161 | |
| 162 | fn set_public_key(mut self, pk: Option<&[u8]>) -> Outcome<Self> { |
| 163 | match &mut self { |
| 164 | Self::X25519(keys) => keys.pk = match pk { |
| 165 | Some(pk) => Some(res!(<[u8; Self::X25519_PK_LEN]>::try_from(&pk[..]))), |
| 166 | None => None, |
| 167 | }, |
| 168 | } |
| 169 | Ok(self) |
| 170 | } |
| 171 | |
| 172 | fn set_secret_key(mut self, sk: Option<&[u8]>) -> Outcome<Self> { |
| 173 | match &mut self { |
| 174 | Self::X25519(keys) => keys.sks = match sk { |
| 175 | Some(sk) => Some(Secret::new(res!( |
| 176 | <[u8; Self::X25519_SK_LEN]>::try_from(&sk[..]) |
| 177 | ))), |
| 178 | None => None, |
| 179 | }, |
| 180 | } |
| 181 | Ok(self) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | impl KeyExchanger for AgreementScheme { |
| 186 | |
| 187 | /// Mints an ephemeral key pair, agrees a session key with `pk`, and hands |
| 188 | /// back the ephemeral public key as the encapsulation of it. |
| 189 | /// |
| 190 | /// The ephemeral key is what makes this worth the thirty-two bytes it costs: |
| 191 | /// under a static sender key, one leaked recipient secret opens every session |
| 192 | /// key ever sent to that recipient. Here it opens only the encapsulations an |
| 193 | /// attacker can still lay hands on. **That protects the encapsulation and not |
| 194 | /// whatever was encrypted under the session key**, which is a distinction |
| 195 | /// anybody describing this to a user has to keep. |
| 196 | fn encap< |
| 197 | const PK_LEN: usize, |
| 198 | const SESSION_KEY_LEN: usize, |
| 199 | const CIPHERTEXT_LEN: usize, |
| 200 | >( |
| 201 | &self, |
| 202 | pk: [u8; PK_LEN], |
| 203 | ) |
| 204 | -> Outcome<( |
| 205 | [u8; SESSION_KEY_LEN], |
| 206 | [u8; CIPHERTEXT_LEN], |
| 207 | )> |
| 208 | { |
| 209 | match self { |
| 210 | Self::X25519(..) => { |
| 211 | let theirs = res!(<[u8; Self::X25519_PK_LEN]>::try_from(&pk[..])); |
| 212 | let mut eph_sk = [0u8; Self::X25519_SK_LEN]; |
| 213 | OsRng.fill_bytes(&mut eph_sk); |
| 214 | let eph_pk = MontgomeryPoint::mul_base_clamped(eph_sk).to_bytes(); |
| 215 | let shared = res!(Self::agree(&eph_sk, &theirs)); |
| 216 | let session = res!(Self::derive(&eph_pk, &theirs, &shared)); |
| 217 | Ok(( |
| 218 | res!(<[u8; SESSION_KEY_LEN]>::try_from(&session[..])), |
| 219 | res!(<[u8; CIPHERTEXT_LEN]>::try_from(&eph_pk[..])), |
| 220 | )) |
| 221 | }, |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// Recovers the session key from the ephemeral public key [`Self::encap`] |
| 226 | /// published beside it. |
| 227 | /// |
| 228 | /// The recipient's own public key goes into the digest, and it is derived |
| 229 | /// from the secret rather than read out of the pair, so a pair holding a |
| 230 | /// public key that does not belong to its secret fails to agree rather than |
| 231 | /// quietly deriving something the sender never derived. |
| 232 | fn decap< |
| 233 | const SESSION_KEY_LEN: usize, |
| 234 | const CIPHERTEXT_LEN: usize, |
| 235 | >( |
| 236 | &self, |
| 237 | ciphertext: [u8; CIPHERTEXT_LEN], |
| 238 | ) |
| 239 | -> Outcome<[u8; SESSION_KEY_LEN]> |
| 240 | { |
| 241 | match self { |
| 242 | Self::X25519(keys) => match &keys.sks { |
| 243 | Some(sks) => { |
| 244 | let sk = sks.expose_secret(); |
| 245 | let eph_pk = res!(<[u8; Self::X25519_PK_LEN]>::try_from(&ciphertext[..])); |
| 246 | let ours = MontgomeryPoint::mul_base_clamped(*sk).to_bytes(); |
| 247 | let shared = res!(Self::agree(sk, &eph_pk)); |
| 248 | let session = res!(Self::derive(&eph_pk, &ours, &shared)); |
| 249 | Ok(res!(<[u8; SESSION_KEY_LEN]>::try_from(&session[..]))) |
| 250 | }, |
| 251 | None => Err(err!( |
| 252 | "Require secret key to de-encapsulate."; |
| 253 | Missing, Configuration)), |
| 254 | }, |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | impl str::FromStr for AgreementScheme { |
| 260 | type Err = Error<ErrTag>; |
| 261 | |
| 262 | fn from_str(name: &str) -> std::result::Result<Self, Self::Err> { |
| 263 | match name { |
| 264 | "X25519" => Ok(Self::new_x25519()), |
| 265 | _ => Err(err!( |
| 266 | "The key agreement scheme '{}' is not recognised.", name; |
| 267 | Invalid, Input)), |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | impl TryFrom<LocalId> for AgreementScheme { |
| 273 | type Error = Error<ErrTag>; |
| 274 | |
| 275 | fn try_from(n: LocalId) -> std::result::Result<Self, Self::Error> { |
| 276 | match n { |
| 277 | LocalId(1) => Ok(Self::new_x25519()), |
| 278 | _ => Err(err!( |
| 279 | "The key agreement scheme with local id {} is not recognised.", n; |
| 280 | Invalid, Input)), |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | impl AgreementScheme { |
| 286 | |
| 287 | pub const X25519_PK_LEN: usize = 32; |
| 288 | pub const X25519_SK_LEN: usize = 32; |
| 289 | pub const X25519_SESSION_KEY_LEN: usize = 32; |
| 290 | // The encapsulation is the ephemeral public key, which is what a |
| 291 | // Diffie-Hellman KEM's ciphertext is. |
| 292 | pub const X25519_CIPHERTEXT_LEN: usize = 32; |
| 293 | |
| 294 | /// What goes into the digest ahead of the keys, so that a session key |
| 295 | /// derived here can never collide with one derived by another protocol from |
| 296 | /// the same shared secret. |
| 297 | pub const X25519_KDF_TAG: &'static str = "FE2O3-X25519-SHA3-256-1"; |
| 298 | |
| 299 | /// Mints a fresh key pair. |
| 300 | pub fn new_x25519() -> Self { |
| 301 | let mut sk = [0u8; Self::X25519_SK_LEN]; |
| 302 | OsRng.fill_bytes(&mut sk); |
| 303 | let pk = MontgomeryPoint::mul_base_clamped(sk).to_bytes(); |
| 304 | Self::X25519(Keys::new(Some(pk), Some(Secret::new(sk)))) |
| 305 | } |
| 306 | |
| 307 | /// The scheme holding no keys, for a caller that is about to install its own. |
| 308 | pub fn empty_x25519() -> Self { |
| 309 | Self::X25519(Keys::default()) |
| 310 | } |
| 311 | |
| 312 | /// Takes a secret somebody already holds, deriving the public key from it |
| 313 | /// rather than being told it. |
| 314 | pub fn x25519_with_secret(sk: &[u8]) |
| 315 | -> Outcome<Self> |
| 316 | { |
| 317 | let sk = res!(<[u8; Self::X25519_SK_LEN]>::try_from(sk)); |
| 318 | let pk = MontgomeryPoint::mul_base_clamped(sk).to_bytes(); |
| 319 | Ok(Self::X25519(Keys::new(Some(pk), Some(Secret::new(sk))))) |
| 320 | } |
| 321 | |
| 322 | /// The public key belonging to an X25519 secret. |
| 323 | pub fn x25519_public_of(sk: &[u8]) |
| 324 | -> Outcome<[u8; Self::X25519_PK_LEN]> |
| 325 | { |
| 326 | let sk = res!(<[u8; Self::X25519_SK_LEN]>::try_from(sk)); |
| 327 | Ok(MontgomeryPoint::mul_base_clamped(sk).to_bytes()) |
| 328 | } |
| 329 | |
| 330 | /// The raw Diffie-Hellman, refusing the all zero result. |
| 331 | /// |
| 332 | /// A public key of small order drives every secret to the same point, |
| 333 | /// whoever holds it, so an all zero agreement is not a shared secret at all; |
| 334 | /// RFC 7748 §6.1 says to check for it and this is that check. |
| 335 | fn agree(sk: &[u8; Self::X25519_SK_LEN], pk: &[u8; Self::X25519_PK_LEN]) |
| 336 | -> Outcome<[u8; Self::X25519_SESSION_KEY_LEN]> |
| 337 | { |
| 338 | let shared = MontgomeryPoint(*pk).mul_clamped(*sk).to_bytes(); |
| 339 | if shared.iter().all(|b| *b == 0) { |
| 340 | return Err(err!( |
| 341 | "The X25519 agreement came to zero, which means the public key it was \ |
| 342 | made against is of small order and agrees the same thing with every \ |
| 343 | secret. It is refused rather than used."; |
| 344 | Invalid, Input, Key)); |
| 345 | } |
| 346 | Ok(shared) |
| 347 | } |
| 348 | |
| 349 | /// The session key, over a transcript that names both public keys. |
| 350 | /// |
| 351 | /// Both ends put in the same three values in the same order, so an attacker |
| 352 | /// who substitutes either public key gets a different session key rather |
| 353 | /// than one the other end will also derive. |
| 354 | fn derive( |
| 355 | eph: &[u8; Self::X25519_PK_LEN], |
| 356 | theirs: &[u8; Self::X25519_PK_LEN], |
| 357 | shared: &[u8; Self::X25519_SESSION_KEY_LEN], |
| 358 | ) |
| 359 | -> Outcome<[u8; Self::X25519_SESSION_KEY_LEN]> |
| 360 | { |
| 361 | let hashed = HashScheme::new_sha3_256().hash::<0>(&[ |
| 362 | Self::X25519_KDF_TAG.as_bytes(), |
| 363 | &eph[..], |
| 364 | &theirs[..], |
| 365 | &shared[..], |
| 366 | ], []); |
| 367 | match hashed.as_hashform() { |
| 368 | HashForm::Bytes32(bytes) => Ok(bytes), |
| 369 | // SHA3-256 gives thirty-two bytes and nothing else reaches here. It |
| 370 | // is an error rather than a fallback value because the one thing a |
| 371 | // key derivation must never do quietly is hand back a constant. |
| 372 | other => Err(err!( |
| 373 | "SHA3-256 returned {:?} rather than thirty-two bytes, so no session \ |
| 374 | key was derived.", other; |
| 375 | Bug, Mismatch)), |
| 376 | } |
| 377 | } |
| 378 | } |