1pub const ROTATION_ATTESTATION_TYPE: &str = "AphRotationAttestation";
26
27pub const DID_DOC_ROTATION_PROPERTY: &str = "https://w3id.org/aph/v1#rotationAttestation";
31
32#[derive(
35 std::fmt::Debug,
36 std::clone::Clone,
37 std::cmp::PartialEq,
38 std::cmp::Eq,
39 serde::Serialize,
40 serde::Deserialize,
41)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43pub struct RotationSuccessor {
44 pub kid: String,
47 pub alg: String,
52 pub public_key_multibase: String,
56 pub not_before: String,
58 pub not_after: String,
60}
61
62#[derive(
65 std::fmt::Debug,
66 std::clone::Clone,
67 std::cmp::PartialEq,
68 std::cmp::Eq,
69 serde::Serialize,
70 serde::Deserialize,
71)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct RotationAttestation {
74 pub aph_version: String,
76 #[serde(rename = "type")]
78 pub r#type: String,
79 pub id: String,
82 pub subject: String,
84 pub predecessor: String,
88 pub successor: RotationSuccessor,
90 pub created: String,
92 pub proof: crate::envelope::EnvelopeProof,
94}
95
96fn signing_input(attestation: &RotationAttestation) -> std::result::Result<String, String> {
99 let mut working = attestation.clone();
100 working.proof.proof_value = String::new();
101 let value = serde_json::to_value(&working).map_err(|e| std::format!("{}", e))?;
102 std::result::Result::Ok(crate::crypto::jcs::canonicalize_rfc8785(&value))
103}
104
105pub fn verify_rotation_attestation(
112 attestation: &RotationAttestation,
113 predecessor_key: &ed25519_dalek::VerifyingKey,
114) -> std::result::Result<(), crate::errors::AphError> {
115 let refuse = crate::errors::AphError::rotation_attestation_invalid;
116
117 if attestation.r#type != ROTATION_ATTESTATION_TYPE {
118 return std::result::Result::Err(refuse(std::format!(
119 "type is `{}`, not `{ROTATION_ATTESTATION_TYPE}`",
120 attestation.r#type
121 )));
122 }
123 if attestation.aph_version != "0.2" {
124 return std::result::Result::Err(refuse(std::format!(
125 "aphVersion `{}` does not declare rotation attestations (they exist from 0.2)",
126 attestation.aph_version
127 )));
128 }
129 let expected_prefix = std::format!("{}#", attestation.subject);
132 if !attestation.predecessor.starts_with(&expected_prefix) {
133 return std::result::Result::Err(refuse(std::format!(
134 "predecessor `{}` is not a key of subject `{}`",
135 attestation.predecessor, attestation.subject
136 )));
137 }
138 if attestation.proof.verification_method != attestation.predecessor {
141 return std::result::Result::Err(refuse(std::format!(
142 "proof.verificationMethod `{}` is not the named predecessor `{}`",
143 attestation.proof.verification_method, attestation.predecessor
144 )));
145 }
146 if attestation.successor.alg != "EdDSA" && attestation.successor.alg != "ES256" {
147 return std::result::Result::Err(refuse(std::format!(
148 "successor.alg `{}` is outside §8.1's set (EdDSA, ES256 — JWS spellings, not TXT's)",
149 attestation.successor.alg
150 )));
151 }
152 if crate::crypto::did_key::decode_multibase_key(&attestation.successor.public_key_multibase)
155 .is_err()
156 {
157 return std::result::Result::Err(refuse(
158 "successor.publicKeyMultibase does not decode as a supported multikey".to_string(),
159 ));
160 }
161 let bounds = (
164 chrono::DateTime::parse_from_rfc3339(&attestation.successor.not_before),
165 chrono::DateTime::parse_from_rfc3339(&attestation.successor.not_after),
166 );
167 match bounds {
168 (std::result::Result::Ok(from), std::result::Result::Ok(until)) if from < until => {}
169 _ => {
170 return std::result::Result::Err(refuse(
171 "successor notBefore/notAfter are not a parseable, ordered RFC 3339 window".to_string(),
172 ));
173 }
174 }
175 if attestation.proof.cryptosuite.as_deref() != std::option::Option::Some("eddsa-jcs-2022") {
180 return std::result::Result::Err(crate::errors::AphError::unsupported_algorithm(
181 "rotation attestation proofs are verified here for eddsa-jcs-2022 only",
182 ));
183 }
184
185 let canonical = signing_input(attestation)
186 .map_err(|e| refuse(std::format!("the statement does not canonicalize: {e}")))?;
187 let signature_bytes = crate::crypto::multibase::base58btc_decode(&attestation.proof.proof_value)
188 .map_err(|_| refuse("proofValue is not multibase base58btc".to_string()))?;
189 let signature_arr: [u8; 64] = match signature_bytes.as_slice().try_into() {
190 std::result::Result::Ok(a) => a,
191 std::result::Result::Err(_) => {
192 return std::result::Result::Err(refuse(
193 "proofValue is not a 64-byte Ed25519 signature".to_string(),
194 ));
195 }
196 };
197 let signature = ed25519_dalek::Signature::from_bytes(&signature_arr);
198 ed25519_dalek::Verifier::verify(predecessor_key, canonical.as_bytes(), &signature)
199 .map_err(|_| refuse("the predecessor's signature does not verify over the statement".to_string()))
200}
201
202pub fn sign_rotation_attestation(
208 attestation: &mut RotationAttestation,
209 signing_key: &ed25519_dalek::SigningKey,
210) -> std::result::Result<(), crate::errors::AphError> {
211 attestation.proof.proof_value = String::new();
212 let canonical = signing_input(attestation).map_err(|e| {
213 crate::errors::AphError::rotation_attestation_invalid(std::format!(
214 "the statement does not canonicalize: {e}"
215 ))
216 })?;
217 let signature = ed25519_dalek::Signer::sign(signing_key, canonical.as_bytes());
218 attestation.proof.proof_value =
219 crate::crypto::multibase::base58btc_encode(&signature.to_bytes());
220 std::result::Result::Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 const RFC8032_TEST1_SEED: [u8; 32] = [
228 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
229 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
230 0x7f, 0x60,
231 ];
232
233 fn signing_key() -> ed25519_dalek::SigningKey {
234 ed25519_dalek::SigningKey::from_bytes(&RFC8032_TEST1_SEED)
235 }
236
237 fn attestation() -> super::RotationAttestation {
238 let key = signing_key();
239 let successor_multibase =
240 crate::crypto::did_key::encode_ed25519(&key.verifying_key());
241 let subject = "did:web:notary.squillo.com";
242 super::RotationAttestation {
243 aph_version: std::string::String::from("0.2"),
244 r#type: std::string::String::from(super::ROTATION_ATTESTATION_TYPE),
245 id: std::string::String::from("urn:uuid:00000000-0000-4000-8000-0000000000r1"),
246 subject: subject.to_string(),
247 predecessor: std::format!("{subject}#k1"),
248 successor: super::RotationSuccessor {
249 kid: std::string::String::from("k2"),
250 alg: std::string::String::from("EdDSA"),
251 public_key_multibase: successor_multibase.trim_start_matches("did:key:").to_string(),
254 not_before: std::string::String::from("2027-01-01T00:00:00Z"),
255 not_after: std::string::String::from("2029-01-01T00:00:00Z"),
256 },
257 created: std::string::String::from("2026-08-29T00:00:00Z"),
258 proof: crate::envelope::EnvelopeProof {
259 r#type: std::string::String::from("DataIntegrityProof"),
260 cryptosuite: std::option::Option::Some(std::string::String::from("eddsa-jcs-2022")),
261 verification_method: std::format!("{subject}#k1"),
262 created: std::string::String::from("2026-08-29T00:00:00Z"),
263 proof_purpose: std::string::String::from("assertionMethod"),
264 proof_value: std::string::String::new(),
265 id: std::option::Option::None,
266 previous_proof: std::option::Option::None,
267 },
268 }
269 }
270
271 #[test]
272 fn a_signed_attestation_verifies_and_one_flipped_byte_refuses() {
273 let key = signing_key();
276 let mut att = attestation();
277 super::sign_rotation_attestation(&mut att, &key).expect("signing succeeds");
278 super::verify_rotation_attestation(&att, &key.verifying_key())
279 .expect("the predecessor's own statement verifies");
280
281 let mut tampered = att.clone();
282 tampered.successor.kid = std::string::String::from("k3");
283 let err = super::verify_rotation_attestation(&tampered, &key.verifying_key())
284 .expect_err("a renamed successor breaks the signature");
285 std::assert_eq!(err.code(), "APH_E024");
286 }
287
288 #[test]
289 fn the_speaker_must_be_a_key_of_the_subject_and_must_be_the_signer() {
290 let key = signing_key();
294 let mut foreign = attestation();
295 foreign.predecessor = std::string::String::from("did:web:other.example.com#k1");
296 super::sign_rotation_attestation(&mut foreign, &key).expect("signs");
297 let err = super::verify_rotation_attestation(&foreign, &key.verifying_key())
298 .expect_err("a predecessor under another DID refuses");
299 std::assert!(std::format!("{err}").contains("not a key of subject"));
300
301 let mut mismatched = attestation();
302 mismatched.proof.verification_method =
303 std::string::String::from("did:web:notary.squillo.com#k9");
304 super::sign_rotation_attestation(&mut mismatched, &key).expect("signs");
305 let err = super::verify_rotation_attestation(&mismatched, &key.verifying_key())
306 .expect_err("a proof by a different key than the named predecessor refuses");
307 std::assert!(std::format!("{err}").contains("not the named predecessor"));
308 }
309
310 #[test]
311 fn malformed_windows_algs_and_types_refuse_before_cryptography() {
312 let key = signing_key();
313 let mut backwards = attestation();
314 backwards.successor.not_before = std::string::String::from("2030-01-01T00:00:00Z");
315 super::sign_rotation_attestation(&mut backwards, &key).expect("signs");
316 std::assert!(super::verify_rotation_attestation(&backwards, &key.verifying_key()).is_err());
317
318 let mut txt_spelled = attestation();
319 txt_spelled.successor.alg = std::string::String::from("ed25519");
320 super::sign_rotation_attestation(&mut txt_spelled, &key).expect("signs");
321 let err = super::verify_rotation_attestation(&txt_spelled, &key.verifying_key())
322 .expect_err("the TXT spelling in the JWS slot is the named vocabulary trap");
323 std::assert!(std::format!("{err}").contains("JWS spellings"));
324
325 let mut wrong_type = attestation();
326 wrong_type.r#type = std::string::String::from("VerifiableCredential");
327 super::sign_rotation_attestation(&mut wrong_type, &key).expect("signs");
328 std::assert!(super::verify_rotation_attestation(&wrong_type, &key.verifying_key()).is_err());
329 }
330
331 #[test]
332 fn the_wire_shape_round_trips_and_refuses_unknown_members() {
333 let key = signing_key();
334 let mut att = attestation();
335 super::sign_rotation_attestation(&mut att, &key).expect("signs");
336 let json = serde_json::to_string(&att).expect("serializes");
337 let back: super::RotationAttestation = serde_json::from_str(&json).expect("parses");
338 std::assert_eq!(back, att);
339 let smuggled = json.replacen('{', "{\"extra\":1,", 1);
340 std::assert!(serde_json::from_str::<super::RotationAttestation>(&smuggled).is_err());
341 }
342}