pub const ROTATION_ATTESTATION_TYPE: &str = "AphRotationAttestation";
pub const DID_DOC_ROTATION_PROPERTY: &str = "https://w3id.org/aph/v1#rotationAttestation";
#[derive(
std::fmt::Debug,
std::clone::Clone,
std::cmp::PartialEq,
std::cmp::Eq,
serde::Serialize,
serde::Deserialize,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RotationSuccessor {
pub kid: String,
pub alg: String,
pub public_key_multibase: String,
pub not_before: String,
pub not_after: String,
}
#[derive(
std::fmt::Debug,
std::clone::Clone,
std::cmp::PartialEq,
std::cmp::Eq,
serde::Serialize,
serde::Deserialize,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RotationAttestation {
pub aph_version: String,
#[serde(rename = "type")]
pub r#type: String,
pub id: String,
pub subject: String,
pub predecessor: String,
pub successor: RotationSuccessor,
pub created: String,
pub proof: crate::envelope::EnvelopeProof,
}
fn signing_input(attestation: &RotationAttestation) -> std::result::Result<String, String> {
let mut working = attestation.clone();
working.proof.proof_value = String::new();
let value = serde_json::to_value(&working).map_err(|e| std::format!("{}", e))?;
std::result::Result::Ok(crate::crypto::jcs::canonicalize_rfc8785(&value))
}
pub fn verify_rotation_attestation(
attestation: &RotationAttestation,
predecessor_key: &ed25519_dalek::VerifyingKey,
) -> std::result::Result<(), crate::errors::AphError> {
let refuse = crate::errors::AphError::rotation_attestation_invalid;
if attestation.r#type != ROTATION_ATTESTATION_TYPE {
return std::result::Result::Err(refuse(std::format!(
"type is `{}`, not `{ROTATION_ATTESTATION_TYPE}`",
attestation.r#type
)));
}
if attestation.aph_version != "0.2" {
return std::result::Result::Err(refuse(std::format!(
"aphVersion `{}` does not declare rotation attestations (they exist from 0.2)",
attestation.aph_version
)));
}
let expected_prefix = std::format!("{}#", attestation.subject);
if !attestation.predecessor.starts_with(&expected_prefix) {
return std::result::Result::Err(refuse(std::format!(
"predecessor `{}` is not a key of subject `{}`",
attestation.predecessor, attestation.subject
)));
}
if attestation.proof.verification_method != attestation.predecessor {
return std::result::Result::Err(refuse(std::format!(
"proof.verificationMethod `{}` is not the named predecessor `{}`",
attestation.proof.verification_method, attestation.predecessor
)));
}
if attestation.successor.alg != "EdDSA" && attestation.successor.alg != "ES256" {
return std::result::Result::Err(refuse(std::format!(
"successor.alg `{}` is outside §8.1's set (EdDSA, ES256 — JWS spellings, not TXT's)",
attestation.successor.alg
)));
}
if crate::crypto::did_key::decode_multibase_key(&attestation.successor.public_key_multibase)
.is_err()
{
return std::result::Result::Err(refuse(
"successor.publicKeyMultibase does not decode as a supported multikey".to_string(),
));
}
let bounds = (
chrono::DateTime::parse_from_rfc3339(&attestation.successor.not_before),
chrono::DateTime::parse_from_rfc3339(&attestation.successor.not_after),
);
match bounds {
(std::result::Result::Ok(from), std::result::Result::Ok(until)) if from < until => {}
_ => {
return std::result::Result::Err(refuse(
"successor notBefore/notAfter are not a parseable, ordered RFC 3339 window".to_string(),
));
}
}
if attestation.proof.cryptosuite.as_deref() != std::option::Option::Some("eddsa-jcs-2022") {
return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
"rotation attestation proofs are verified here for eddsa-jcs-2022 only",
));
}
let canonical = signing_input(attestation)
.map_err(|e| refuse(std::format!("the statement does not canonicalize: {e}")))?;
let signature_bytes = crate::crypto::multibase::base58btc_decode(&attestation.proof.proof_value)
.map_err(|_| refuse("proofValue is not multibase base58btc".to_string()))?;
let signature_arr: [u8; 64] = match signature_bytes.as_slice().try_into() {
std::result::Result::Ok(a) => a,
std::result::Result::Err(_) => {
return std::result::Result::Err(refuse(
"proofValue is not a 64-byte Ed25519 signature".to_string(),
));
}
};
let signature = ed25519_dalek::Signature::from_bytes(&signature_arr);
ed25519_dalek::Verifier::verify(predecessor_key, canonical.as_bytes(), &signature)
.map_err(|_| refuse("the predecessor's signature does not verify over the statement".to_string()))
}
pub fn sign_rotation_attestation(
attestation: &mut RotationAttestation,
signing_key: &ed25519_dalek::SigningKey,
) -> std::result::Result<(), crate::errors::AphError> {
attestation.proof.proof_value = String::new();
let canonical = signing_input(attestation).map_err(|e| {
crate::errors::AphError::rotation_attestation_invalid(std::format!(
"the statement does not canonicalize: {e}"
))
})?;
let signature = ed25519_dalek::Signer::sign(signing_key, canonical.as_bytes());
attestation.proof.proof_value =
crate::crypto::multibase::base58btc_encode(&signature.to_bytes());
std::result::Result::Ok(())
}
#[cfg(test)]
mod tests {
const RFC8032_TEST1_SEED: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
0x7f, 0x60,
];
fn signing_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&RFC8032_TEST1_SEED)
}
fn attestation() -> super::RotationAttestation {
let key = signing_key();
let successor_multibase =
crate::crypto::did_key::encode_ed25519(&key.verifying_key());
let subject = "did:web:notary.squillo.com";
super::RotationAttestation {
aph_version: std::string::String::from("0.2"),
r#type: std::string::String::from(super::ROTATION_ATTESTATION_TYPE),
id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-0000000000r1"),
subject: subject.to_string(),
predecessor: std::format!("{subject}#k1"),
successor: super::RotationSuccessor {
kid: std::string::String::from("k2"),
alg: std::string::String::from("EdDSA"),
public_key_multibase: successor_multibase.trim_start_matches("did:key:").to_string(),
not_before: std::string::String::from("2027-01-01T00:00:00Z"),
not_after: std::string::String::from("2029-01-01T00:00:00Z"),
},
created: std::string::String::from("2026-08-29T00:00:00Z"),
proof: crate::envelope::EnvelopeProof {
r#type: std::string::String::from("DataIntegrityProof"),
cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
verification_method: std::format!("{subject}#k1"),
created: std::string::String::from("2026-08-29T00:00:00Z"),
proof_purpose: std::string::String::from("assertionMethod"),
proof_value: std::string::String::new(),
id: std::option::Option::None,
previous_proof: std::option::Option::None,
},
}
}
#[test]
fn a_signed_attestation_verifies_and_one_flipped_byte_refuses() {
let key = signing_key();
let mut att = attestation();
super::sign_rotation_attestation(&mut att, &key).expect("signing succeeds");
super::verify_rotation_attestation(&att, &key.verifying_key())
.expect("the predecessor's own statement verifies");
let mut tampered = att.clone();
tampered.successor.kid = std::string::String::from("k3");
let err = super::verify_rotation_attestation(&tampered, &key.verifying_key())
.expect_err("a renamed successor breaks the signature");
std::assert_eq!(err.code(), "APH_E024");
}
#[test]
fn the_speaker_must_be_a_key_of_the_subject_and_must_be_the_signer() {
let key = signing_key();
let mut foreign = attestation();
foreign.predecessor = std::string::String::from("did:web:other.example.com#k1");
super::sign_rotation_attestation(&mut foreign, &key).expect("signs");
let err = super::verify_rotation_attestation(&foreign, &key.verifying_key())
.expect_err("a predecessor under another DID refuses");
std::assert!(std::format!("{err}").contains("not a key of subject"));
let mut mismatched = attestation();
mismatched.proof.verification_method =
std::string::String::from("did:web:notary.squillo.com#k9");
super::sign_rotation_attestation(&mut mismatched, &key).expect("signs");
let err = super::verify_rotation_attestation(&mismatched, &key.verifying_key())
.expect_err("a proof by a different key than the named predecessor refuses");
std::assert!(std::format!("{err}").contains("not the named predecessor"));
}
#[test]
fn malformed_windows_algs_and_types_refuse_before_cryptography() {
let key = signing_key();
let mut backwards = attestation();
backwards.successor.not_before = std::string::String::from("2030-01-01T00:00:00Z");
super::sign_rotation_attestation(&mut backwards, &key).expect("signs");
std::assert!(super::verify_rotation_attestation(&backwards, &key.verifying_key()).is_err());
let mut txt_spelled = attestation();
txt_spelled.successor.alg = std::string::String::from("ed25519");
super::sign_rotation_attestation(&mut txt_spelled, &key).expect("signs");
let err = super::verify_rotation_attestation(&txt_spelled, &key.verifying_key())
.expect_err("the TXT spelling in the JWS slot is the named vocabulary trap");
std::assert!(std::format!("{err}").contains("JWS spellings"));
let mut wrong_type = attestation();
wrong_type.r#type = std::string::String::from("VerifiableCredential");
super::sign_rotation_attestation(&mut wrong_type, &key).expect("signs");
std::assert!(super::verify_rotation_attestation(&wrong_type, &key.verifying_key()).is_err());
}
#[test]
fn the_wire_shape_round_trips_and_refuses_unknown_members() {
let key = signing_key();
let mut att = attestation();
super::sign_rotation_attestation(&mut att, &key).expect("signs");
let json = serde_json::to_string(&att).expect("serializes");
let back: super::RotationAttestation = serde_json::from_str(&json).expect("parses");
std::assert_eq!(back, att);
let smuggled = json.replacen('{', "{\"extra\":1,", 1);
std::assert!(serde_json::from_str::<super::RotationAttestation>(&smuggled).is_err());
}
}