oxedyne/fe2o3/fe2o3_crypto/src/credential.rs
15.1 KiB, 53 runs
created by r1870400018:11560, 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 | //! Generic signed credentials: "issuer attests that this public key is bound |
| 2 | //! to this subject for this time range". |
| 3 | //! |
| 4 | //! A [`SignedCredential`] is a self-contained, typed record carrying a |
| 5 | //! signature over a canonical byte encoding of its fields. It is |
| 6 | //! agnostic about what issuers and subjects mean semantically -- a |
| 7 | //! caller is free to decide that a "subject" is a device, a peer, a |
| 8 | //! user, a delegated agent, or anything else with an identifier. The |
| 9 | //! credential format is just "issuer says this binding holds from A to |
| 10 | //! B". |
| 11 | //! |
| 12 | //! # Design points |
| 13 | //! |
| 14 | //! - *Issuer and subject IDs are opaque bytes*. Applications hash whatever |
| 15 | //! they consider stable (a public key, a name, a URL) into the ID |
| 16 | //! space of their choice. This module does not impose a hash. |
| 17 | //! - *Signature scheme is named, not typed*. The scheme's registered |
| 18 | //! name (see [`SignatureScheme`] and its `Debug` impl) is stored in |
| 19 | //! the credential so a verifier can reconstruct the right algorithm |
| 20 | //! from the serialised bytes alone. |
| 21 | //! - *Self-signed is a special case*. When `issuer_id == subject_id`, |
| 22 | //! the credential asserts that the holder of the bound secret key |
| 23 | //! has declared their own identity. Useful for bootstrap: the very |
| 24 | //! first credential in a system cannot be signed by anyone else. |
| 25 | //! - *Validity window is inclusive on the lower bound and exclusive on |
| 26 | //! the upper*. `0` as `valid_to` is a sentinel for "no expiry". |
| 27 | //! - *No at-rest encryption*. A credential's purpose is to be shown; |
| 28 | //! it carries only public data plus a signature. If you want to |
| 29 | //! protect the credential's existence (not its contents), encrypt |
| 30 | //! it at a different layer. |
| 31 | //! |
| 32 | //! # Canonical byte encoding |
| 33 | //! |
| 34 | //! The signed bytes are produced by [`SignedCredential::signed_bytes`] |
| 35 | //! and consist of, in order: |
| 36 | //! |
| 37 | //! ```text |
| 38 | //! [u8 version = 1] |
| 39 | //! [u32 LE scheme_len][scheme_bytes] |
| 40 | //! [u32 LE subject_id_len][subject_id] |
| 41 | //! [u32 LE subject_pk_len][subject_pk] |
| 42 | //! [u32 LE issuer_id_len][issuer_id] |
| 43 | //! [u64 LE valid_from] |
| 44 | //! [u64 LE valid_to] |
| 45 | //! ``` |
| 46 | //! |
| 47 | //! The encoding is length-prefixed rather than delimiter-based so |
| 48 | //! there is no ambiguity for callers that put arbitrary bytes in the |
| 49 | //! id fields. The leading version byte lets a future schema change |
| 50 | //! surface as a verify-fails-loudly rather than a quietly-different |
| 51 | //! hash. |
| 52 | //! |
| 53 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 54 | //! Anthropic Claude |
| 55 | |
| 56 | use crate::sign::SignatureScheme; |
| 57 | |
| 58 | use oxedyne_fe2o3_core::prelude::*; |
| 59 | use oxedyne_fe2o3_iop_crypto::{ |
| 60 | keys::KeyManager, |
| 61 | sign::Signer, |
| 62 | }; |
| 63 | use oxedyne_fe2o3_jdat::prelude::*; |
| 64 | |
| 65 | use std::{ |
| 66 | str::FromStr, |
| 67 | time::{ |
| 68 | SystemTime, |
| 69 | UNIX_EPOCH, |
| 70 | }, |
| 71 | }; |
| 72 | |
| 73 | |
| 74 | // Bump the version if the field set or the layout changes in a way that would |
| 75 | // alter the signed bytes. |
| 76 | pub const CREDENTIAL_VERSION: u8 = 1; |
| 77 | |
| 78 | |
| 79 | /// A signed attestation that `issuer_id` vouches for `subject_pk` being bound |
| 80 | /// to `subject_id` for a stated time range. |
| 81 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 82 | pub struct SignedCredential { |
| 83 | // Both identifiers are opaque: a hash of a name, a hash of a key, an |
| 84 | // assigned serial, whatever the caller decides. They are equal in a |
| 85 | // self-signed credential. |
| 86 | pub subject_id: Vec<u8>, |
| 87 | // Zero length is legal and binds an identifier alone, with no key. |
| 88 | pub subject_pk: Vec<u8>, |
| 89 | pub issuer_id: Vec<u8>, |
| 90 | pub scheme: String, // SignatureScheme's Debug string |
| 91 | pub valid_from: u64, // seconds since epoch, inclusive; 0 for no start |
| 92 | pub valid_to: u64, // seconds since epoch, exclusive; 0 for no expiry |
| 93 | pub sig: Vec<u8>, |
| 94 | } |
| 95 | |
| 96 | impl SignedCredential { |
| 97 | |
| 98 | /// A self-signed credential: the holder of the secret key declares who it |
| 99 | /// is, and vouches for nothing else. `subject_scheme` must carry both keys. |
| 100 | pub fn self_sign( |
| 101 | subject_id: Vec<u8>, |
| 102 | subject_scheme: &SignatureScheme, |
| 103 | valid_from: u64, |
| 104 | valid_to: u64, |
| 105 | ) |
| 106 | -> Outcome<Self> |
| 107 | { |
| 108 | let subject_pk = res!(res!(subject_scheme.get_public_key()).ok_or_else(|| err!( |
| 109 | "self_sign requires the signature scheme to carry a public key."; |
| 110 | Missing, Configuration))) |
| 111 | .to_vec(); |
| 112 | Self::sign( |
| 113 | subject_id.clone(), |
| 114 | subject_pk, |
| 115 | subject_id, |
| 116 | subject_scheme, |
| 117 | valid_from, |
| 118 | valid_to, |
| 119 | ) |
| 120 | } |
| 121 | |
| 122 | /// A third-party credential: the issuer attests that `subject_pk` belongs |
| 123 | /// to `subject_id`. |
| 124 | /// |
| 125 | /// The subject's public key is passed in rather than derived, so the issuer |
| 126 | /// never needs the subject's secret key. `issuer_scheme` must carry both of |
| 127 | /// the issuer's. |
| 128 | pub fn sign( |
| 129 | subject_id: Vec<u8>, |
| 130 | subject_pk: Vec<u8>, |
| 131 | issuer_id: Vec<u8>, |
| 132 | issuer_scheme: &SignatureScheme, |
| 133 | valid_from: u64, |
| 134 | valid_to: u64, |
| 135 | ) |
| 136 | -> Outcome<Self> |
| 137 | { |
| 138 | if valid_to != 0 && valid_to <= valid_from { |
| 139 | return Err(err!( |
| 140 | "Credential validity window is empty: valid_from {}, \ |
| 141 | valid_to {}.", valid_from, valid_to; |
| 142 | Invalid, Input, Size)); |
| 143 | } |
| 144 | let scheme = fmt!("{:?}", issuer_scheme); |
| 145 | let mut cred = Self { |
| 146 | subject_id, |
| 147 | subject_pk, |
| 148 | issuer_id, |
| 149 | scheme, |
| 150 | valid_from, |
| 151 | valid_to, |
| 152 | sig: Vec::new(), |
| 153 | }; |
| 154 | let bytes = cred.signed_bytes(); |
| 155 | cred.sig = res!(issuer_scheme.sign(&bytes)); |
| 156 | Ok(cred) |
| 157 | } |
| 158 | |
| 159 | /// The canonical encoding the signature covers; the module header gives the |
| 160 | /// layout. |
| 161 | pub fn signed_bytes(&self) -> Vec<u8> { |
| 162 | let scheme_bytes = self.scheme.as_bytes(); |
| 163 | let cap = 1 |
| 164 | + 4 + scheme_bytes.len() |
| 165 | + 4 + self.subject_id.len() |
| 166 | + 4 + self.subject_pk.len() |
| 167 | + 4 + self.issuer_id.len() |
| 168 | + 8 + 8; |
| 169 | let mut out = Vec::with_capacity(cap); |
| 170 | out.push(CREDENTIAL_VERSION); |
| 171 | out.extend_from_slice(&(scheme_bytes.len() as u32).to_le_bytes()); |
| 172 | out.extend_from_slice(scheme_bytes); |
| 173 | out.extend_from_slice(&(self.subject_id.len() as u32).to_le_bytes()); |
| 174 | out.extend_from_slice(&self.subject_id); |
| 175 | out.extend_from_slice(&(self.subject_pk.len() as u32).to_le_bytes()); |
| 176 | out.extend_from_slice(&self.subject_pk); |
| 177 | out.extend_from_slice(&(self.issuer_id.len() as u32).to_le_bytes()); |
| 178 | out.extend_from_slice(&self.issuer_id); |
| 179 | out.extend_from_slice(&self.valid_from.to_le_bytes()); |
| 180 | out.extend_from_slice(&self.valid_to.to_le_bytes()); |
| 181 | out |
| 182 | } |
| 183 | |
| 184 | /// Verifies the signature and that the credential is in its window now. |
| 185 | /// |
| 186 | /// A bad signature, an expired window and an unrecognised scheme each carry |
| 187 | /// their own error tag, so a caller can tell them apart. |
| 188 | pub fn verify(&self, issuer_pk: &[u8]) -> Outcome<()> { |
| 189 | let now = SystemTime::now() |
| 190 | .duration_since(UNIX_EPOCH) |
| 191 | .map(|d| d.as_secs()) |
| 192 | .unwrap_or(0); |
| 193 | self.verify_at(issuer_pk, now) |
| 194 | } |
| 195 | |
| 196 | /// As [`Self::verify`], against a supplied `now` in seconds. |
| 197 | pub fn verify_at(&self, issuer_pk: &[u8], now: u64) -> Outcome<()> { |
| 198 | // Validity window check first -- cheap, fails fast on |
| 199 | // expired credentials without wasting a signature verify. |
| 200 | if self.valid_from != 0 && now < self.valid_from { |
| 201 | return Err(err!( |
| 202 | "Credential not yet valid: now = {}, valid_from = {}.", |
| 203 | now, self.valid_from; |
| 204 | Invalid, Security, Order)); |
| 205 | } |
| 206 | if self.valid_to != 0 && now >= self.valid_to { |
| 207 | return Err(err!( |
| 208 | "Credential expired: now = {}, valid_to = {}.", |
| 209 | now, self.valid_to; |
| 210 | Invalid, Security, Order)); |
| 211 | } |
| 212 | // Reconstruct the scheme from its stored name and clone it |
| 213 | // with the supplied public key so we can call verify. |
| 214 | let scheme = res!(SignatureScheme::from_str(&self.scheme)); |
| 215 | let scheme = res!(scheme.clone_with_keys(Some(issuer_pk), None)); |
| 216 | let bytes = self.signed_bytes(); |
| 217 | let ok = res!(scheme.verify(&bytes, &self.sig)); |
| 218 | if !ok { |
| 219 | return Err(err!( |
| 220 | "Credential signature did not verify under the supplied \ |
| 221 | issuer public key (scheme: {}).", self.scheme; |
| 222 | Invalid, Security, Mismatch)); |
| 223 | } |
| 224 | Ok(()) |
| 225 | } |
| 226 | |
| 227 | /// Is this credential self-signed? |
| 228 | pub fn is_self_signed(&self) -> bool { |
| 229 | self.issuer_id == self.subject_id |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | |
| 234 | impl ToDat for SignedCredential { |
| 235 | fn to_dat(&self) -> Outcome<Dat> { |
| 236 | let mut m = DaticleMap::new(); |
| 237 | m.insert(dat!("subject_id"), Dat::bytdat(self.subject_id.clone())); |
| 238 | m.insert(dat!("subject_pk"), Dat::bytdat(self.subject_pk.clone())); |
| 239 | m.insert(dat!("issuer_id"), Dat::bytdat(self.issuer_id.clone())); |
| 240 | m.insert(dat!("scheme"), dat!(self.scheme.clone())); |
| 241 | m.insert(dat!("valid_from"), dat!(self.valid_from)); |
| 242 | m.insert(dat!("valid_to"), dat!(self.valid_to)); |
| 243 | m.insert(dat!("sig"), Dat::bytdat(self.sig.clone())); |
| 244 | Ok(Dat::Map(m)) |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | impl FromDat for SignedCredential { |
| 249 | fn from_dat(mut dat: Dat) -> Outcome<Self> { |
| 250 | let subject_id = try_extract_dat!( |
| 251 | res!(dat.map_remove_must(&dat!("subject_id"))), |
| 252 | BU8, BU16, BU32, BU64, |
| 253 | ); |
| 254 | let subject_pk = try_extract_dat!( |
| 255 | res!(dat.map_remove_must(&dat!("subject_pk"))), |
| 256 | BU8, BU16, BU32, BU64, |
| 257 | ); |
| 258 | let issuer_id = try_extract_dat!( |
| 259 | res!(dat.map_remove_must(&dat!("issuer_id"))), |
| 260 | BU8, BU16, BU32, BU64, |
| 261 | ); |
| 262 | let scheme = try_extract_dat!( |
| 263 | res!(dat.map_remove_must(&dat!("scheme"))), |
| 264 | Str, |
| 265 | ); |
| 266 | let valid_from = match res!(dat.map_remove_must(&dat!("valid_from"))) { |
| 267 | Dat::U64(n) => n, |
| 268 | Dat::U32(n) => n as u64, |
| 269 | other => return Err(err!( |
| 270 | "SignedCredential 'valid_from' must be u64, got {:?}.", |
| 271 | other.kind(); |
| 272 | Invalid, Input, Mismatch)), |
| 273 | }; |
| 274 | let valid_to = match res!(dat.map_remove_must(&dat!("valid_to"))) { |
| 275 | Dat::U64(n) => n, |
| 276 | Dat::U32(n) => n as u64, |
| 277 | other => return Err(err!( |
| 278 | "SignedCredential 'valid_to' must be u64, got {:?}.", |
| 279 | other.kind(); |
| 280 | Invalid, Input, Mismatch)), |
| 281 | }; |
| 282 | let sig = try_extract_dat!( |
| 283 | res!(dat.map_remove_must(&dat!("sig"))), |
| 284 | BU8, BU16, BU32, BU64, |
| 285 | ); |
| 286 | Ok(Self { |
| 287 | subject_id, |
| 288 | subject_pk, |
| 289 | issuer_id, |
| 290 | scheme, |
| 291 | valid_from, |
| 292 | valid_to, |
| 293 | sig, |
| 294 | }) |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | |
| 299 | #[cfg(test)] |
| 300 | mod tests { |
| 301 | use super::*; |
| 302 | |
| 303 | fn ed25519_scheme() -> SignatureScheme { |
| 304 | SignatureScheme::new_ed25519() |
| 305 | } |
| 306 | |
| 307 | fn issuer_pk(scheme: &SignatureScheme) -> Vec<u8> { |
| 308 | scheme.get_public_key().unwrap().unwrap().to_vec() |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn self_sign_round_trip_verifies() -> Outcome<()> { |
| 313 | let scheme = ed25519_scheme(); |
| 314 | let pk = issuer_pk(&scheme); |
| 315 | let cred = res!(SignedCredential::self_sign( |
| 316 | vec![0x42; 32], |
| 317 | &scheme, |
| 318 | 0, |
| 319 | 0, |
| 320 | )); |
| 321 | assert!(cred.is_self_signed()); |
| 322 | res!(cred.verify(&pk)); |
| 323 | Ok(()) |
| 324 | } |
| 325 | |
| 326 | #[test] |
| 327 | fn third_party_sign_round_trip_verifies() -> Outcome<()> { |
| 328 | let issuer = ed25519_scheme(); |
| 329 | let subject = ed25519_scheme(); |
| 330 | let subject_pk = issuer_pk(&subject); |
| 331 | let issuer_pk_bytes = issuer_pk(&issuer); |
| 332 | let cred = res!(SignedCredential::sign( |
| 333 | vec![0x01; 16], |
| 334 | subject_pk, |
| 335 | vec![0x02; 16], |
| 336 | &issuer, |
| 337 | 0, |
| 338 | 0, |
| 339 | )); |
| 340 | assert!(!cred.is_self_signed()); |
| 341 | res!(cred.verify(&issuer_pk_bytes)); |
| 342 | Ok(()) |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn tampered_field_fails_verify() -> Outcome<()> { |
| 347 | let scheme = ed25519_scheme(); |
| 348 | let pk = issuer_pk(&scheme); |
| 349 | let mut cred = res!(SignedCredential::self_sign( |
| 350 | vec![0x55; 32], &scheme, 0, 0, |
| 351 | )); |
| 352 | // Flip a bit in subject_pk after signing. |
| 353 | if !cred.subject_pk.is_empty() { |
| 354 | cred.subject_pk[0] ^= 0x01; |
| 355 | } |
| 356 | assert!(cred.verify(&pk).is_err()); |
| 357 | Ok(()) |
| 358 | } |
| 359 | |
| 360 | #[test] |
| 361 | fn wrong_issuer_pk_fails_verify() -> Outcome<()> { |
| 362 | let scheme = ed25519_scheme(); |
| 363 | let other = ed25519_scheme(); |
| 364 | let cred = res!(SignedCredential::self_sign( |
| 365 | vec![0x66; 32], &scheme, 0, 0, |
| 366 | )); |
| 367 | let wrong_pk = issuer_pk(&other); |
| 368 | assert!(cred.verify(&wrong_pk).is_err()); |
| 369 | Ok(()) |
| 370 | } |
| 371 | |
| 372 | #[test] |
| 373 | fn validity_window_not_yet_valid() -> Outcome<()> { |
| 374 | let scheme = ed25519_scheme(); |
| 375 | let pk = issuer_pk(&scheme); |
| 376 | let cred = res!(SignedCredential::self_sign( |
| 377 | vec![0x77; 32], &scheme, 2_000_000_000, 0, |
| 378 | )); |
| 379 | // "Now" before valid_from. |
| 380 | assert!(cred.verify_at(&pk, 1_000_000_000).is_err()); |
| 381 | // "Now" at or after valid_from passes. |
| 382 | res!(cred.verify_at(&pk, 2_000_000_000)); |
| 383 | Ok(()) |
| 384 | } |
| 385 | |
| 386 | #[test] |
| 387 | fn validity_window_expired() -> Outcome<()> { |
| 388 | let scheme = ed25519_scheme(); |
| 389 | let pk = issuer_pk(&scheme); |
| 390 | let cred = res!(SignedCredential::self_sign( |
| 391 | vec![0x88; 32], &scheme, 0, 2_000_000_000, |
| 392 | )); |
| 393 | // "Now" before valid_to passes. |
| 394 | res!(cred.verify_at(&pk, 1_999_999_999)); |
| 395 | // "Now" at or after valid_to fails (upper bound is exclusive). |
| 396 | assert!(cred.verify_at(&pk, 2_000_000_000).is_err()); |
| 397 | Ok(()) |
| 398 | } |
| 399 | |
| 400 | #[test] |
| 401 | fn empty_validity_window_rejected_at_sign_time() -> Outcome<()> { |
| 402 | let scheme = ed25519_scheme(); |
| 403 | // valid_to <= valid_from (and != 0) is nonsense. |
| 404 | assert!(SignedCredential::self_sign( |
| 405 | vec![0x99; 32], &scheme, 100, 50, |
| 406 | ).is_err()); |
| 407 | assert!(SignedCredential::self_sign( |
| 408 | vec![0x99; 32], &scheme, 100, 100, |
| 409 | ).is_err()); |
| 410 | Ok(()) |
| 411 | } |
| 412 | |
| 413 | #[test] |
| 414 | fn jdat_round_trip_preserves_signature() -> Outcome<()> { |
| 415 | let scheme = ed25519_scheme(); |
| 416 | let pk = issuer_pk(&scheme); |
| 417 | let cred = res!(SignedCredential::self_sign( |
| 418 | vec![0xab; 32], &scheme, 0, 0, |
| 419 | )); |
| 420 | let dat = res!(cred.to_dat()); |
| 421 | let back = res!(SignedCredential::from_dat(dat)); |
| 422 | assert_eq!(back, cred); |
| 423 | res!(back.verify(&pk)); |
| 424 | Ok(()) |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn version_byte_in_signed_bytes() { |
| 429 | let cred = SignedCredential { |
| 430 | subject_id: vec![0x01], |
| 431 | subject_pk: vec![0x02], |
| 432 | issuer_id: vec![0x03], |
| 433 | scheme: "Ed25519".to_string(), |
| 434 | valid_from: 0, |
| 435 | valid_to: 0, |
| 436 | sig: Vec::new(), |
| 437 | }; |
| 438 | let bytes = cred.signed_bytes(); |
| 439 | assert_eq!(bytes[0], CREDENTIAL_VERSION); |
| 440 | } |
| 441 | } |