oxedyne/fe2o3/fe2o3_crypto/src/command.rs
16.3 KiB, 43 runs
created by r1870400018:11564, 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 | //! Authenticated command envelopes. |
| 2 | //! |
| 3 | //! A [`SignedCommand`] bundles a command name, a typed argument |
| 4 | //! payload, a timestamp, a fresh nonce and a signature into a single |
| 5 | //! self-describing unit that can ride any transport -- HTTP POST, |
| 6 | //! WebSocket frame, UDP datagram, filesystem drop. Verifying the |
| 7 | //! envelope requires only the signer's public key plus (for |
| 8 | //! freshness) a clock and a nonce tracker. |
| 9 | //! |
| 10 | //! # Design points |
| 11 | //! |
| 12 | //! - *Transport-agnostic*. The envelope does not assume HTTP or |
| 13 | //! WebSocket or Shield; applications serialise it via JDAT and |
| 14 | //! send the bytes over whatever pipe fits. |
| 15 | //! - *Replay protection lives outside*. The envelope carries a |
| 16 | //! timestamp and a nonce; a stateful nonce tracker that rejects |
| 17 | //! re-used `(signer_id, nonce)` pairs within a time window is a |
| 18 | //! companion primitive (see `fe2o3_shield::replay`). This module |
| 19 | //! deliberately owns only the envelope and the signature. |
| 20 | //! - *Signature scheme is named*. Like [`super::credential::SignedCredential`], |
| 21 | //! the issuer's scheme name travels with the envelope so a |
| 22 | //! verifier reconstructs the right algorithm from the wire bytes. |
| 23 | //! - *Arguments are [`Dat`]*. Any serialisable typed payload is |
| 24 | //! acceptable; the envelope canonicalises them through the JDAT |
| 25 | //! binary encoding for signing, so any two peers agree on the |
| 26 | //! signed bytes without coordinating a schema. |
| 27 | //! |
| 28 | //! # Canonical byte encoding |
| 29 | //! |
| 30 | //! Produced by [`SignedCommand::signed_bytes`] in order: |
| 31 | //! |
| 32 | //! ```text |
| 33 | //! [u8 version = 1] |
| 34 | //! [u32 LE scheme_len][scheme_bytes] |
| 35 | //! [u32 LE signer_id_len][signer_id] |
| 36 | //! [u32 LE cmd_len][cmd_bytes] |
| 37 | //! [u32 LE args_len][args_bytes] |
| 38 | //! [u64 LE timestamp] |
| 39 | //! [32 bytes nonce] |
| 40 | //! ``` |
| 41 | //! |
| 42 | //! `args_bytes` is the output of [`Dat::as_bytes`] for the envelope's |
| 43 | //! [`args`] field, so the signed encoding is insensitive to map |
| 44 | //! iteration order and other JDAT ambiguities. |
| 45 | //! |
| 46 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 47 | //! Anthropic Claude |
| 48 | |
| 49 | use crate::sign::SignatureScheme; |
| 50 | |
| 51 | use oxedyne_fe2o3_core::prelude::*; |
| 52 | use oxedyne_fe2o3_iop_crypto::{ |
| 53 | keys::KeyManager, |
| 54 | sign::Signer, |
| 55 | }; |
| 56 | use oxedyne_fe2o3_jdat::prelude::*; |
| 57 | |
| 58 | use std::{ |
| 59 | str::FromStr, |
| 60 | time::{ |
| 61 | Duration, |
| 62 | SystemTime, |
| 63 | UNIX_EPOCH, |
| 64 | }, |
| 65 | }; |
| 66 | |
| 67 | use rand_core::{ |
| 68 | OsRng, |
| 69 | RngCore, |
| 70 | }; |
| 71 | |
| 72 | |
| 73 | // Bump the version if the field set or the layout changes in a way that would |
| 74 | // alter the signed bytes. The nonce is 32 bytes to match the Kademlia, |
| 75 | // RecordId and credential-identifier widths already used across the stack. |
| 76 | pub const COMMAND_VERSION: u8 = 1; |
| 77 | pub const COMMAND_NONCE_LEN: usize = 32; |
| 78 | |
| 79 | |
| 80 | /// A signed command envelope. |
| 81 | /// |
| 82 | /// Signing nothing else, this envelope proves who issued a command and when. |
| 83 | /// Rejecting a repeat within the freshness window is a separate nonce tracker's |
| 84 | /// job, not this type's. |
| 85 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 86 | pub struct SignedCommand { |
| 87 | // Opaque to the envelope: the verifier looks a public key up by it. |
| 88 | pub signer_id: Vec<u8>, |
| 89 | pub scheme: String, // SignatureScheme's Debug string |
| 90 | // Opaque UTF-8; authorisation and dispatch by verb are the caller's job. |
| 91 | pub cmd: String, |
| 92 | pub args: Dat, // signed as its JDAT binary encoding |
| 93 | pub timestamp: u64, // seconds since epoch |
| 94 | pub nonce: [u8; COMMAND_NONCE_LEN], // fresh per command |
| 95 | pub sig: Vec<u8>, |
| 96 | } |
| 97 | |
| 98 | impl SignedCommand { |
| 99 | |
| 100 | /// Signs a command, stamping the clock and drawing a nonce from `OsRng`. |
| 101 | /// |
| 102 | /// `signer_scheme` must carry the secret key as well as the public one. |
| 103 | pub fn sign( |
| 104 | signer_id: Vec<u8>, |
| 105 | cmd: impl Into<String>, |
| 106 | args: Dat, |
| 107 | signer_scheme: &SignatureScheme, |
| 108 | ) |
| 109 | -> Outcome<Self> |
| 110 | { |
| 111 | let timestamp = SystemTime::now() |
| 112 | .duration_since(UNIX_EPOCH) |
| 113 | .map(|d| d.as_secs()) |
| 114 | .unwrap_or(0); |
| 115 | let mut nonce = [0u8; COMMAND_NONCE_LEN]; |
| 116 | OsRng.fill_bytes(&mut nonce); |
| 117 | Self::sign_with( |
| 118 | signer_id, |
| 119 | cmd.into(), |
| 120 | args, |
| 121 | signer_scheme, |
| 122 | timestamp, |
| 123 | nonce, |
| 124 | ) |
| 125 | } |
| 126 | |
| 127 | /// As [`Self::sign`], with the clock and the nonce supplied, for a |
| 128 | /// deterministic test or a caller with its own sources. |
| 129 | pub fn sign_with( |
| 130 | signer_id: Vec<u8>, |
| 131 | cmd: String, |
| 132 | args: Dat, |
| 133 | signer_scheme: &SignatureScheme, |
| 134 | timestamp: u64, |
| 135 | nonce: [u8; COMMAND_NONCE_LEN], |
| 136 | ) |
| 137 | -> Outcome<Self> |
| 138 | { |
| 139 | let scheme = fmt!("{:?}", signer_scheme); |
| 140 | let mut env = Self { |
| 141 | signer_id, |
| 142 | scheme, |
| 143 | cmd, |
| 144 | args, |
| 145 | timestamp, |
| 146 | nonce, |
| 147 | sig: Vec::new(), |
| 148 | }; |
| 149 | let bytes = res!(env.signed_bytes()); |
| 150 | env.sig = res!(signer_scheme.sign(&bytes)); |
| 151 | Ok(env) |
| 152 | } |
| 153 | |
| 154 | /// The canonical encoding the signature covers; the module header gives the |
| 155 | /// layout. |
| 156 | pub fn signed_bytes(&self) -> Outcome<Vec<u8>> { |
| 157 | let scheme_bytes = self.scheme.as_bytes(); |
| 158 | let cmd_bytes = self.cmd.as_bytes(); |
| 159 | let args_bytes = res!(self.args.as_bytes()); |
| 160 | let cap = 1 |
| 161 | + 4 + scheme_bytes.len() |
| 162 | + 4 + self.signer_id.len() |
| 163 | + 4 + cmd_bytes.len() |
| 164 | + 4 + args_bytes.len() |
| 165 | + 8 |
| 166 | + COMMAND_NONCE_LEN; |
| 167 | let mut out = Vec::with_capacity(cap); |
| 168 | out.push(COMMAND_VERSION); |
| 169 | out.extend_from_slice(&(scheme_bytes.len() as u32).to_le_bytes()); |
| 170 | out.extend_from_slice(scheme_bytes); |
| 171 | out.extend_from_slice(&(self.signer_id.len() as u32).to_le_bytes()); |
| 172 | out.extend_from_slice(&self.signer_id); |
| 173 | out.extend_from_slice(&(cmd_bytes.len() as u32).to_le_bytes()); |
| 174 | out.extend_from_slice(cmd_bytes); |
| 175 | out.extend_from_slice(&(args_bytes.len() as u32).to_le_bytes()); |
| 176 | out.extend_from_slice(&args_bytes); |
| 177 | out.extend_from_slice(&self.timestamp.to_le_bytes()); |
| 178 | out.extend_from_slice(&self.nonce); |
| 179 | Ok(out) |
| 180 | } |
| 181 | |
| 182 | /// Verifies the signature and **nothing else**. A replayed command |
| 183 | /// verifies here; [`Self::verify_fresh`] is what bounds it by the clock. |
| 184 | pub fn verify(&self, signer_pk: &[u8]) -> Outcome<()> { |
| 185 | let scheme = res!(SignatureScheme::from_str(&self.scheme)); |
| 186 | let scheme = res!(scheme.clone_with_keys(Some(signer_pk), None)); |
| 187 | let bytes = res!(self.signed_bytes()); |
| 188 | let ok = res!(scheme.verify(&bytes, &self.sig)); |
| 189 | if !ok { |
| 190 | return Err(err!( |
| 191 | "SignedCommand signature did not verify under the \ |
| 192 | supplied signer public key (scheme: {}).", self.scheme; |
| 193 | Invalid, Security, Mismatch)); |
| 194 | } |
| 195 | Ok(()) |
| 196 | } |
| 197 | |
| 198 | /// Verifies the signature and that the timestamp falls within `window` of |
| 199 | /// now, in either direction. |
| 200 | /// |
| 201 | /// The nonce is still the caller's to check against its own replay tracker: |
| 202 | /// a command repeated inside the window passes this. |
| 203 | pub fn verify_fresh( |
| 204 | &self, |
| 205 | signer_pk: &[u8], |
| 206 | window: Duration, |
| 207 | ) |
| 208 | -> Outcome<()> |
| 209 | { |
| 210 | let now = SystemTime::now() |
| 211 | .duration_since(UNIX_EPOCH) |
| 212 | .map(|d| d.as_secs()) |
| 213 | .unwrap_or(0); |
| 214 | self.verify_fresh_at(signer_pk, now, window) |
| 215 | } |
| 216 | |
| 217 | /// As [`Self::verify_fresh`], against a supplied `now` in seconds. |
| 218 | pub fn verify_fresh_at( |
| 219 | &self, |
| 220 | signer_pk: &[u8], |
| 221 | now: u64, |
| 222 | window: Duration, |
| 223 | ) |
| 224 | -> Outcome<()> |
| 225 | { |
| 226 | let window_secs = window.as_secs(); |
| 227 | let diff = if now >= self.timestamp { |
| 228 | now - self.timestamp |
| 229 | } else { |
| 230 | self.timestamp - now |
| 231 | }; |
| 232 | if diff > window_secs { |
| 233 | return Err(err!( |
| 234 | "SignedCommand timestamp {} is outside the {} s \ |
| 235 | freshness window around now = {}.", |
| 236 | self.timestamp, window_secs, now; |
| 237 | Invalid, Security, Order)); |
| 238 | } |
| 239 | self.verify(signer_pk) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | |
| 244 | impl ToDat for SignedCommand { |
| 245 | fn to_dat(&self) -> Outcome<Dat> { |
| 246 | let mut m = DaticleMap::new(); |
| 247 | m.insert(dat!("signer_id"), Dat::bytdat(self.signer_id.clone())); |
| 248 | m.insert(dat!("scheme"), dat!(self.scheme.clone())); |
| 249 | m.insert(dat!("cmd"), dat!(self.cmd.clone())); |
| 250 | m.insert(dat!("args"), self.args.clone()); |
| 251 | m.insert(dat!("timestamp"), dat!(self.timestamp)); |
| 252 | m.insert(dat!("nonce"), Dat::bytdat(self.nonce.to_vec())); |
| 253 | m.insert(dat!("sig"), Dat::bytdat(self.sig.clone())); |
| 254 | Ok(Dat::Map(m)) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | impl FromDat for SignedCommand { |
| 259 | fn from_dat(mut dat: Dat) -> Outcome<Self> { |
| 260 | let signer_id = try_extract_dat!( |
| 261 | res!(dat.map_remove_must(&dat!("signer_id"))), |
| 262 | BU8, BU16, BU32, BU64, |
| 263 | ); |
| 264 | let scheme = try_extract_dat!( |
| 265 | res!(dat.map_remove_must(&dat!("scheme"))), |
| 266 | Str, |
| 267 | ); |
| 268 | let cmd = try_extract_dat!( |
| 269 | res!(dat.map_remove_must(&dat!("cmd"))), |
| 270 | Str, |
| 271 | ); |
| 272 | let args = res!(dat.map_remove_must(&dat!("args"))); |
| 273 | let timestamp = match res!(dat.map_remove_must(&dat!("timestamp"))) { |
| 274 | Dat::U64(n) => n, |
| 275 | Dat::U32(n) => n as u64, |
| 276 | other => return Err(err!( |
| 277 | "SignedCommand 'timestamp' must be u64, got {:?}.", |
| 278 | other.kind(); |
| 279 | Invalid, Input, Mismatch)), |
| 280 | }; |
| 281 | let nonce_bytes = try_extract_dat!( |
| 282 | res!(dat.map_remove_must(&dat!("nonce"))), |
| 283 | BU8, BU16, BU32, BU64, |
| 284 | ); |
| 285 | if nonce_bytes.len() != COMMAND_NONCE_LEN { |
| 286 | return Err(err!( |
| 287 | "SignedCommand nonce length is {}, expected {}.", |
| 288 | nonce_bytes.len(), COMMAND_NONCE_LEN; |
| 289 | Invalid, Input, Size)); |
| 290 | } |
| 291 | let mut nonce = [0u8; COMMAND_NONCE_LEN]; |
| 292 | nonce.copy_from_slice(&nonce_bytes); |
| 293 | let sig = try_extract_dat!( |
| 294 | res!(dat.map_remove_must(&dat!("sig"))), |
| 295 | BU8, BU16, BU32, BU64, |
| 296 | ); |
| 297 | Ok(Self { |
| 298 | signer_id, |
| 299 | scheme, |
| 300 | cmd, |
| 301 | args, |
| 302 | timestamp, |
| 303 | nonce, |
| 304 | sig, |
| 305 | }) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | |
| 310 | #[cfg(test)] |
| 311 | mod tests { |
| 312 | use super::*; |
| 313 | |
| 314 | fn ed25519_scheme() -> SignatureScheme { |
| 315 | SignatureScheme::new_ed25519() |
| 316 | } |
| 317 | |
| 318 | fn signer_pk(scheme: &SignatureScheme) -> Vec<u8> { |
| 319 | scheme.get_public_key().unwrap().unwrap().to_vec() |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn sign_and_verify_round_trip() -> Outcome<()> { |
| 324 | let scheme = ed25519_scheme(); |
| 325 | let pk = signer_pk(&scheme); |
| 326 | let env = res!(SignedCommand::sign( |
| 327 | vec![0x01; 32], |
| 328 | "admin_login", |
| 329 | dat!("unlock-please"), |
| 330 | &scheme, |
| 331 | )); |
| 332 | res!(env.verify(&pk)); |
| 333 | Ok(()) |
| 334 | } |
| 335 | |
| 336 | #[test] |
| 337 | fn tampered_cmd_fails_verify() -> Outcome<()> { |
| 338 | let scheme = ed25519_scheme(); |
| 339 | let pk = signer_pk(&scheme); |
| 340 | let mut env = res!(SignedCommand::sign( |
| 341 | vec![0x02; 32], |
| 342 | "reload", |
| 343 | Dat::Empty, |
| 344 | &scheme, |
| 345 | )); |
| 346 | env.cmd = "shutdown".to_string(); |
| 347 | assert!(env.verify(&pk).is_err()); |
| 348 | Ok(()) |
| 349 | } |
| 350 | |
| 351 | #[test] |
| 352 | fn tampered_nonce_fails_verify() -> Outcome<()> { |
| 353 | let scheme = ed25519_scheme(); |
| 354 | let pk = signer_pk(&scheme); |
| 355 | let mut env = res!(SignedCommand::sign( |
| 356 | vec![0x03; 32], |
| 357 | "reload", |
| 358 | Dat::Empty, |
| 359 | &scheme, |
| 360 | )); |
| 361 | env.nonce[0] ^= 0xff; |
| 362 | assert!(env.verify(&pk).is_err()); |
| 363 | Ok(()) |
| 364 | } |
| 365 | |
| 366 | #[test] |
| 367 | fn tampered_timestamp_fails_verify() -> Outcome<()> { |
| 368 | let scheme = ed25519_scheme(); |
| 369 | let pk = signer_pk(&scheme); |
| 370 | let mut env = res!(SignedCommand::sign_with( |
| 371 | vec![0x04; 32], |
| 372 | "reload".to_string(), |
| 373 | Dat::Empty, |
| 374 | &scheme, |
| 375 | 1_000_000_000, |
| 376 | [0x11; COMMAND_NONCE_LEN], |
| 377 | )); |
| 378 | env.timestamp = 1_000_000_001; |
| 379 | assert!(env.verify(&pk).is_err()); |
| 380 | Ok(()) |
| 381 | } |
| 382 | |
| 383 | #[test] |
| 384 | fn tampered_args_fails_verify() -> Outcome<()> { |
| 385 | let scheme = ed25519_scheme(); |
| 386 | let pk = signer_pk(&scheme); |
| 387 | let env = res!(SignedCommand::sign( |
| 388 | vec![0x05; 32], |
| 389 | "store", |
| 390 | dat!("original"), |
| 391 | &scheme, |
| 392 | )); |
| 393 | // Reconstruct with different args but same signature. |
| 394 | let forged = SignedCommand { |
| 395 | args: dat!("forged"), |
| 396 | ..env |
| 397 | }; |
| 398 | assert!(forged.verify(&pk).is_err()); |
| 399 | Ok(()) |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn wrong_signer_pk_fails_verify() -> Outcome<()> { |
| 404 | let signer = ed25519_scheme(); |
| 405 | let other = ed25519_scheme(); |
| 406 | let env = res!(SignedCommand::sign( |
| 407 | vec![0x06; 32], |
| 408 | "reload", |
| 409 | Dat::Empty, |
| 410 | &signer, |
| 411 | )); |
| 412 | assert!(env.verify(&signer_pk(&other)).is_err()); |
| 413 | Ok(()) |
| 414 | } |
| 415 | |
| 416 | #[test] |
| 417 | fn freshness_window_accepts_in_window() -> Outcome<()> { |
| 418 | let scheme = ed25519_scheme(); |
| 419 | let pk = signer_pk(&scheme); |
| 420 | let env = res!(SignedCommand::sign_with( |
| 421 | vec![0x07; 32], |
| 422 | "reload".to_string(), |
| 423 | Dat::Empty, |
| 424 | &scheme, |
| 425 | 1_000_000_000, |
| 426 | [0x22; COMMAND_NONCE_LEN], |
| 427 | )); |
| 428 | // Slightly past timestamp, well inside a 60s window. |
| 429 | res!(env.verify_fresh_at(&pk, 1_000_000_030, Duration::from_secs(60))); |
| 430 | // Slightly before timestamp (clock skew the other way). |
| 431 | res!(env.verify_fresh_at(&pk, 999_999_970, Duration::from_secs(60))); |
| 432 | Ok(()) |
| 433 | } |
| 434 | |
| 435 | #[test] |
| 436 | fn freshness_window_rejects_stale() -> Outcome<()> { |
| 437 | let scheme = ed25519_scheme(); |
| 438 | let pk = signer_pk(&scheme); |
| 439 | let env = res!(SignedCommand::sign_with( |
| 440 | vec![0x08; 32], |
| 441 | "reload".to_string(), |
| 442 | Dat::Empty, |
| 443 | &scheme, |
| 444 | 1_000_000_000, |
| 445 | [0x33; COMMAND_NONCE_LEN], |
| 446 | )); |
| 447 | // Two minutes past timestamp, 60s window. |
| 448 | assert!(env.verify_fresh_at( |
| 449 | &pk, 1_000_000_120, Duration::from_secs(60), |
| 450 | ).is_err()); |
| 451 | Ok(()) |
| 452 | } |
| 453 | |
| 454 | #[test] |
| 455 | fn two_successive_signs_have_distinct_nonces() -> Outcome<()> { |
| 456 | let scheme = ed25519_scheme(); |
| 457 | let a = res!(SignedCommand::sign( |
| 458 | vec![0x09; 32], "ping", Dat::Empty, &scheme, |
| 459 | )); |
| 460 | let b = res!(SignedCommand::sign( |
| 461 | vec![0x09; 32], "ping", Dat::Empty, &scheme, |
| 462 | )); |
| 463 | assert_ne!(a.nonce, b.nonce, |
| 464 | "two successive sign() calls produced the same nonce -- \ |
| 465 | OsRng is not behaving"); |
| 466 | Ok(()) |
| 467 | } |
| 468 | |
| 469 | #[test] |
| 470 | fn jdat_round_trip_preserves_signature() -> Outcome<()> { |
| 471 | let scheme = ed25519_scheme(); |
| 472 | let pk = signer_pk(&scheme); |
| 473 | let env = res!(SignedCommand::sign( |
| 474 | vec![0x0a; 32], |
| 475 | "store", |
| 476 | mapdat!{ |
| 477 | "k" => "val", |
| 478 | "n" => 42u64, |
| 479 | }, |
| 480 | &scheme, |
| 481 | )); |
| 482 | let dat = res!(env.to_dat()); |
| 483 | let back = res!(SignedCommand::from_dat(dat)); |
| 484 | assert_eq!(back, env); |
| 485 | res!(back.verify(&pk)); |
| 486 | Ok(()) |
| 487 | } |
| 488 | |
| 489 | #[test] |
| 490 | fn from_dat_rejects_wrong_nonce_length() { |
| 491 | let mut m = DaticleMap::new(); |
| 492 | m.insert(dat!("signer_id"), Dat::bytdat(vec![0u8; 4])); |
| 493 | m.insert(dat!("scheme"), dat!("Ed25519")); |
| 494 | m.insert(dat!("cmd"), dat!("x")); |
| 495 | m.insert(dat!("args"), Dat::Empty); |
| 496 | m.insert(dat!("timestamp"), dat!(0u64)); |
| 497 | m.insert(dat!("nonce"), Dat::bytdat(vec![0u8; 16])); // wrong |
| 498 | m.insert(dat!("sig"), Dat::bytdat(Vec::new())); |
| 499 | assert!(SignedCommand::from_dat(Dat::Map(m)).is_err()); |
| 500 | } |
| 501 | |
| 502 | #[test] |
| 503 | fn version_byte_in_signed_bytes() -> Outcome<()> { |
| 504 | let scheme = ed25519_scheme(); |
| 505 | let env = res!(SignedCommand::sign_with( |
| 506 | vec![0x0b; 32], |
| 507 | "x".to_string(), |
| 508 | Dat::Empty, |
| 509 | &scheme, |
| 510 | 0, |
| 511 | [0u8; COMMAND_NONCE_LEN], |
| 512 | )); |
| 513 | let bytes = res!(env.signed_bytes()); |
| 514 | assert_eq!(bytes[0], COMMAND_VERSION); |
| 515 | Ok(()) |
| 516 | } |
| 517 | } |