pub const IDENTITY_STATE_SIZE: usize = 1 + 32 + 4;
pub const IDENTITY_STATE_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityStateError {
InvalidLength {
got: usize,
},
UnsupportedVersion {
found: u8,
},
GenerationWentBackwards {
current: u32,
requested: u32,
},
GenerationExhausted,
}
impl core::fmt::Display for IdentityStateError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidLength { got } => write!(
f,
"identity state must be {IDENTITY_STATE_SIZE} bytes, got {got}"
),
Self::UnsupportedVersion { found } => write!(
f,
"identity state version {found} is newer than this library \
understands (expected {IDENTITY_STATE_VERSION})"
),
Self::GenerationWentBackwards { current, requested } => write!(
f,
"issuer generation may only move forward: at {current}, \
asked for {requested}"
),
Self::GenerationExhausted => write!(
f,
"issuer generation is exhausted at u32::MAX; rotate the \
identity key instead"
),
}
}
}
impl std::error::Error for IdentityStateError {}
#[derive(Clone, Copy)]
pub struct IdentityState {
pub seed: [u8; 32],
pub generation: u32,
}
impl IdentityState {
pub fn to_bytes(&self) -> [u8; IDENTITY_STATE_SIZE] {
let mut out = [0u8; IDENTITY_STATE_SIZE];
out[0] = IDENTITY_STATE_VERSION;
out[1..33].copy_from_slice(&self.seed);
out[33..37].copy_from_slice(&self.generation.to_le_bytes());
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, IdentityStateError> {
if bytes.len() != IDENTITY_STATE_SIZE {
return Err(IdentityStateError::InvalidLength { got: bytes.len() });
}
if bytes[0] != IDENTITY_STATE_VERSION {
return Err(IdentityStateError::UnsupportedVersion { found: bytes[0] });
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes[1..33]);
Ok(Self {
seed,
generation: u32::from_le_bytes([bytes[33], bytes[34], bytes[35], bytes[36]]),
})
}
pub fn check_rotation(current: u32, next: u32) -> Result<u32, IdentityStateError> {
if next < current {
return Err(IdentityStateError::GenerationWentBackwards {
current,
requested: next,
});
}
Ok(next)
}
pub fn next_generation(current: u32) -> Result<u32, IdentityStateError> {
current
.checked_add(1)
.ok_or(IdentityStateError::GenerationExhausted)
}
}
impl core::fmt::Debug for IdentityState {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("IdentityState")
.field("seed", &"<redacted>")
.field("generation", &self.generation)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_seed_and_generation() {
let state = IdentityState {
seed: [0xA5; 32],
generation: 0xDEAD_BEEF,
};
let decoded = IdentityState::from_bytes(&state.to_bytes()).unwrap();
assert_eq!(decoded.seed, state.seed);
assert_eq!(decoded.generation, state.generation);
}
#[test]
fn layout_is_pinned() {
let bytes = IdentityState {
seed: [0x11; 32],
generation: 258,
}
.to_bytes();
assert_eq!(bytes.len(), 37);
assert_eq!(bytes[0], 1, "version");
assert_eq!(&bytes[1..33], &[0x11u8; 32], "seed");
assert_eq!(&bytes[33..37], &[0x02, 0x01, 0x00, 0x00], "u32 LE");
}
#[test]
fn rejects_wrong_length_and_future_versions() {
assert_eq!(
IdentityState::from_bytes(&[]).unwrap_err(),
IdentityStateError::InvalidLength { got: 0 }
);
assert_eq!(
IdentityState::from_bytes(&[0u8; 32]).unwrap_err(),
IdentityStateError::InvalidLength { got: 32 }
);
let mut future = IdentityState {
seed: [0; 32],
generation: 0,
}
.to_bytes();
future[0] = 2;
assert_eq!(
IdentityState::from_bytes(&future).unwrap_err(),
IdentityStateError::UnsupportedVersion { found: 2 }
);
}
#[test]
fn rotation_rules_are_monotonic() {
assert_eq!(IdentityState::check_rotation(3, 4).unwrap(), 4);
assert_eq!(
IdentityState::check_rotation(3, 3).unwrap(),
3,
"idempotent: re-applying a persisted generation is not an error"
);
assert_eq!(
IdentityState::check_rotation(3, 2).unwrap_err(),
IdentityStateError::GenerationWentBackwards {
current: 3,
requested: 2
}
);
}
#[test]
fn the_ceiling_is_reappliable_but_not_advanceable() {
assert_eq!(
IdentityState::check_rotation(u32::MAX, u32::MAX).unwrap(),
u32::MAX,
"an issuer at the ceiling must be able to re-apply its own \
persisted generation on restart"
);
assert_eq!(
IdentityState::check_rotation(u32::MAX, u32::MAX - 1).unwrap_err(),
IdentityStateError::GenerationWentBackwards {
current: u32::MAX,
requested: u32::MAX - 1
}
);
assert_eq!(IdentityState::next_generation(0).unwrap(), 1);
assert_eq!(
IdentityState::next_generation(u32::MAX - 1).unwrap(),
u32::MAX,
"the last generation is reachable, not skipped"
);
assert_eq!(
IdentityState::next_generation(u32::MAX).unwrap_err(),
IdentityStateError::GenerationExhausted,
"advancing is the operation that runs out"
);
}
#[test]
fn next_generation_output_always_passes_check_rotation() {
for current in [0u32, 1, 7, 65_535, u32::MAX - 2, u32::MAX - 1] {
let next = IdentityState::next_generation(current).expect("not at the ceiling");
assert_eq!(
IdentityState::check_rotation(current, next).unwrap(),
next,
"advancing from {current} must produce an acceptable target"
);
}
}
#[test]
fn debug_never_prints_the_seed() {
let rendered = format!(
"{:?}",
IdentityState {
seed: [0x7F; 32],
generation: 1,
}
);
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains("127"), "no seed bytes in Debug output");
}
}