Oregami
Repositories/oxedyne/fe2o3

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
49use crate::sign::SignatureScheme;
50
51use oxedyne_fe2o3_core::prelude::*;
52use oxedyne_fe2o3_iop_crypto::{
53 keys::KeyManager,
54 sign::Signer,
55};
56use oxedyne_fe2o3_jdat::prelude::*;
57
58use std::{
59 str::FromStr,
60 time::{
61 Duration,
62 SystemTime,
63 UNIX_EPOCH,
64 },
65};
66
67use 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.
76pub const COMMAND_VERSION: u8 = 1;
77pub 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)]
86pub 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
98impl 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
244impl 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
258impl 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)]
311mod 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}