Skip to main content

aph_core/
rotation.rs

1//! RFC 0001 — the signed key-rotation attestation (spec/aph-0.2.md).
2//!
3//! One claim, made by the CURRENT key: *this named successor is mine.* What
4//! it buys: a verifier can check that a successor was named BY ITS
5//! PREDECESSOR rather than merely served from the same origin — §8.4.7's
6//! bare overlap upgraded to a chain a stranger can walk. What it cannot buy,
7//! per the RFC's own §5 and repeated here so no caller oversells it: a
8//! stolen current key signs a rotation too; a verifier meeting an identity
9//! for the first time has nothing to chain from (continuity, not genesis);
10//! and it is no recovery from key LOSS, because the lost key can no longer
11//! speak.
12//!
13//! Zero new cryptography (RFC 0001 §2.3): the statement is signed exactly as
14//! a lone-proof envelope is — RFC 8785 JCS over the object with
15//! `proof.proofValue` PRESENT AND EMPTIED (never removed; §8.2 records the
16//! draft era that got that wrong), `eddsa-jcs-2022`, multibase proof value.
17//! Publication (RFC 0001 §3.1): the DID Document property
18//! [`DID_DOC_ROTATION_PROPERTY`], full-URI keyed so JSON-LD expansion
19//! cannot drop it, atomic with the successor key's own entry.
20
21/// The statement's `type` literal. A verifier refuses any other value: this
22/// object is not a VC on purpose (RFC 0001 §2.4 — a VC invites
23/// `credentialStatus`, whose freshness bound is the opposite of what a
24/// rotation attestation needs).
25pub const ROTATION_ATTESTATION_TYPE: &str = "AphRotationAttestation";
26
27/// The DID Document property carrying attestations (RFC 0001 §3.1): an
28/// array of statements, keyed by full URI so a JSON-LD-processing resolver
29/// survives expansion without a context definition APH does not serve.
30pub const DID_DOC_ROTATION_PROPERTY: &str = "https://w3id.org/aph/v1#rotationAttestation";
31
32/// The successor a rotation attestation names: the key, its fragment, and
33/// its activation bounds.
34#[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  /// The fragment a later `proof.verificationMethod` will carry — the join
45  /// key of the chain.
46  pub kid: String,
47  /// §8.1's JWS spelling (`EdDSA`, `ES256`) — NOT the §8.4.5 TXT spelling
48  /// (`ed25519`, `p256`). The mapping between the two vocabularies is
49  /// stated once in spec/aph-0.2.md §5 rather than rediscovered per
50  /// implementation, exactly as RFC 0001 §2.2 asked.
51  pub alg: String,
52  /// The successor key bytes themselves, multibase. Naming a `kid` without
53  /// the bytes would let whoever controls publication bind the `kid` to a
54  /// key of their choosing — the attack this mechanism exists to stop.
55  pub public_key_multibase: String,
56  /// RFC 3339 activation lower bound, §8.4.5's vocabulary.
57  pub not_before: String,
58  /// RFC 3339 activation upper bound.
59  pub not_after: String,
60}
61
62/// RFC 0001 §2.1's statement, byte-for-byte the published shape. Every
63/// member is inside the signed bytes for the reason its §2.2 table states.
64#[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  /// `"0.2"` — the version that declares this statement.
75  pub aph_version: String,
76  /// Always [`ROTATION_ATTESTATION_TYPE`].
77  #[serde(rename = "type")]
78  pub r#type: String,
79  /// Stable name for THIS statement, so a retraction or an operator's
80  /// records can refer to exactly one attestation.
81  pub id: String,
82  /// The identity the statement is about.
83  pub subject: String,
84  /// The SPEAKER, as a DID URL including its `#kid` fragment. A verifier
85  /// chains on this value; it is never inferred from where the record was
86  /// found.
87  pub predecessor: String,
88  /// The key being named.
89  pub successor: RotationSuccessor,
90  /// Orders two attestations naming the same predecessor.
91  pub created: String,
92  /// §8.2's proof block, unchanged — the same shape an envelope carries.
93  pub proof: crate::envelope::EnvelopeProof,
94}
95
96/// Canonical signing bytes: the statement with `proof.proofValue` present
97/// and EMPTIED — §7.2.1's lone-proof base, "minus is not empty" honoured.
98fn 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
105/// Verifies a rotation attestation against the PREDECESSOR's public key —
106/// the key the verifier already trusts, resolved through §8.4 or remembered
107/// from prior contact. Everything checkable is checked before the
108/// signature, and every refusal is `APH_E024` carrying the specific defect,
109/// because "the attestation is invalid" with no reason teaches an operator
110/// nothing.
111pub 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  // The speaker must be a key OF the subject: a predecessor fragment under
130  // any other DID is a statement about someone else's identity.
131  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  // The proof must be BY the predecessor it names — the chain value and the
139  // signing key are one claim, not two.
140  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  // The successor bytes must BE a key of a codec this protocol carries;
153  // an undecodable successor is a chain link to nothing.
154  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  // Activation bounds must be a real window, both parseable — fail-closed,
162  // like every timestamp rule in this crate.
163  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  // This implementation verifies the eddsa-jcs-2022 carriage; an ES256 or
176  // JWS-carried attestation is refused BY NAME rather than falling through
177  // to a wrong-algorithm signature failure — the same honesty arm the
178  // envelope verifier carries.
179  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
202/// Signs an attestation in place: `proof.proofValue` is computed over the
203/// lone-proof base and written back multibase. Exists for the committed
204/// test vectors and for operators minting real statements through the
205/// reference; it accepts a caller-held signing key and never touches key
206/// storage.
207pub 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  // TEST KEY ONLY: RFC 8032 §7.1 TEST 1's secret key, the same published
226  // vector the signing suite already cites. It authorizes nothing.
227  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        // The successor key is illustratively the same test key: the chain
252        // rule under test is predecessor-signs, not successor-differs.
253        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    // The mechanism end to end: predecessor signs, verifier checks with the
274    // predecessor's PUBLIC key, and integrity is all-or-nothing.
275    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    // The two structural rules RFC 0001 §2.2 makes load-bearing: the chain
291    // value names a key OF the subject, and the proof is BY that key —
292    // each refused by name, before any cryptography.
293    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}