Skip to main content

ojak_sig/
integrity.rs

1//! FEP-8b32 Object Integrity Proof verification (`eddsa-jcs-2022` and
2//! `mldsa44-jcs-2024`).
3//!
4//! Fedify-based servers (GoToSocial, hackers.pub, …) attach a Data Integrity
5//! `proof` to the activities they deliver, authenticating the activity itself
6//! rather than the HTTP transport. This lets a recipient accept an activity
7//! whose HTTP Signature it can't verify (a different signature spec, a shared
8//! inbox, or a forwarded/relayed delivery) as long as the author's proof holds.
9//!
10//! The algorithm mirrors Fedify's `verifyProof`
11//! (`packages/fedify/src/sig/proof.ts`) and the W3C "Data Integrity EdDSA
12//! Cryptosuites" `eddsa-jcs-2022` suite:
13//!
14//! 1. `proofConfig` = the document's `@context` plus the fixed proof metadata
15//!    (`type`, `cryptosuite`, `verificationMethod`, `proofPurpose`, `created`).
16//! 2. `unsecuredDocument` = the activity with every `proof` form removed.
17//! 3. `hashData` = SHA-256(JCS(proofConfig)) ‖ SHA-256(JCS(unsecuredDocument)).
18//! 4. Verify the `proofValue` over `hashData` with the key resolved from the
19//!    proof's `verificationMethod`.
20//!
21//! `mldsa44-jcs-2024` (W3C "Verifiable Credential Data Integrity — Quantum
22//! Resistant Cryptosuites") is the same algorithm over ML-DSA-44 instead of
23//! Ed25519, with one deliberate difference in step 1 that the specification
24//! itself calls out: the proof configuration always takes the document's
25//! `@context`, rather than keeping whatever the proof carried.
26
27use crate::{CryptoRng, Error};
28use ed25519_dalek::Verifier as _;
29use serde_json::{Map, Value};
30use sha2::{Digest as _, Sha256};
31
32/// Multicodec header for an `ed25519-pub` key inside a Multikey.
33const ED25519_PUB_MULTICODEC: [u8; 2] = [0xed, 0x01];
34
35/// Multicodec header for an `mldsa-44-pub` key inside a Multikey: code `0x1210`
36/// as an unsigned varint.
37const MLDSA44_PUB_MULTICODEC: [u8; 2] = [0x90, 0x24];
38
39/// The cryptosuites this module can verify.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Cryptosuite {
42    /// Ed25519 over JCS, the suite Fedify-based servers sign with.
43    EddsaJcs2022,
44    /// ML-DSA-44 over JCS, the post-quantum suite Mastodon 4.7 added.
45    Mldsa44Jcs2024,
46}
47
48impl Cryptosuite {
49    /// The `cryptosuite` string as it appears in a proof.
50    #[must_use]
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::EddsaJcs2022 => "eddsa-jcs-2022",
54            Self::Mldsa44Jcs2024 => "mldsa44-jcs-2024",
55        }
56    }
57
58    fn from_str(value: &str) -> Option<Self> {
59        match value {
60            "eddsa-jcs-2022" => Some(Self::EddsaJcs2022),
61            "mldsa44-jcs-2024" => Some(Self::Mldsa44Jcs2024),
62            _ => None,
63        }
64    }
65}
66
67/// A public key resolved from a proof's `verificationMethod`.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum PublicKey {
70    Ed25519(Box<[u8; 32]>),
71    /// An ML-DSA-44 key, as the 1312 bytes FIPS 204 encodes it in.
72    MlDsa44(Box<[u8]>),
73}
74
75/// The DER of an Ed25519 `SubjectPublicKeyInfo` before its 32 key bytes
76/// (RFC 8410): the algorithm identifier `1.3.101.112` and the bit string's
77/// header.
78const ED25519_SPKI_PREFIX: [u8; 12] = [
79    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
80];
81
82/// The DER of an ML-DSA-44 `SubjectPublicKeyInfo` before its 1312 key bytes
83/// (`id-ml-dsa-44`, as X.509 carries it): the algorithm identifier
84/// `2.16.840.1.101.3.4.3.17` and the bit string's header.
85const MLDSA44_SPKI_PREFIX: [u8; 22] = [
86    0x30, 0x82, 0x05, 0x32, 0x30, 0x0b, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03,
87    0x11, 0x03, 0x82, 0x05, 0x21, 0x00,
88];
89
90/// How long an ML-DSA-44 public key is.
91const MLDSA44_PUBLIC_KEY_BYTES: usize = 1312;
92
93impl PublicKey {
94    /// The key as a DER `SubjectPublicKeyInfo`.
95    ///
96    /// # Errors
97    /// Returns an error if an ML-DSA-44 key is not 1312 bytes long.
98    pub fn to_spki_der(&self) -> Result<Vec<u8>, Error> {
99        match self {
100            Self::Ed25519(key) => Ok([ED25519_SPKI_PREFIX.as_slice(), key.as_slice()].concat()),
101            Self::MlDsa44(key) if key.len() == MLDSA44_PUBLIC_KEY_BYTES => {
102                Ok([MLDSA44_SPKI_PREFIX.as_slice(), key].concat())
103            }
104            Self::MlDsa44(key) => Err(Error::Key(format!(
105                "an ML-DSA-44 key of {} bytes",
106                key.len()
107            ))),
108        }
109    }
110
111    /// The key as an SPKI PEM, as OpenSSL's `public_to_pem` writes it: a
112    /// `PUBLIC KEY` block, base64 in lines of 64, each ending in LF. This is
113    /// what Mastodon stores a remote actor's Multikey as, and a FASP's key.
114    ///
115    /// # Errors
116    /// As [`PublicKey::to_spki_der`].
117    pub fn to_spki_pem(&self) -> Result<String, Error> {
118        use base64::Engine as _;
119
120        let encoded = base64::engine::general_purpose::STANDARD.encode(self.to_spki_der()?);
121        let mut pem = String::from("-----BEGIN PUBLIC KEY-----\n");
122        for line in encoded.as_bytes().chunks(64) {
123            pem.push_str(core::str::from_utf8(line).unwrap_or_default());
124            pem.push('\n');
125        }
126        pem.push_str("-----END PUBLIC KEY-----\n");
127        Ok(pem)
128    }
129
130    /// The Ed25519 or ML-DSA-44 key of a DER `SubjectPublicKeyInfo`.
131    ///
132    /// # Errors
133    /// Returns an error if `der` is not an SPKI of either.
134    pub fn from_spki_der(der: &[u8]) -> Result<Self, Error> {
135        if let Some(key) = der.strip_prefix(&ED25519_SPKI_PREFIX) {
136            return Ok(Self::Ed25519(Box::new(ed25519_bytes(key)?)));
137        }
138        if let Some(key) = der.strip_prefix(&MLDSA44_SPKI_PREFIX)
139            && key.len() == MLDSA44_PUBLIC_KEY_BYTES
140        {
141            return Ok(Self::MlDsa44(key.to_vec().into_boxed_slice()));
142        }
143        Err(Error::Key(
144            "not an Ed25519 or ML-DSA-44 public key in SPKI".into(),
145        ))
146    }
147
148    /// The Ed25519 or ML-DSA-44 key of an SPKI PEM: the base64 between its
149    /// armour lines, however it is wrapped.
150    ///
151    /// # Errors
152    /// Returns an error if `pem` is not base64 armour around an SPKI of
153    /// either.
154    pub fn from_spki_pem(pem: &str) -> Result<Self, Error> {
155        use base64::Engine as _;
156
157        let body: String = pem
158            .lines()
159            .map(str::trim)
160            .filter(|line| !line.starts_with("-----"))
161            .collect();
162        let der = base64::engine::general_purpose::STANDARD
163            .decode(body.as_bytes())
164            .map_err(|_| Error::Key("the public key is not base64".into()))?;
165        Self::from_spki_der(&der)
166    }
167}
168
169/// Find a usable assertion-method integrity proof on `document`, if present,
170/// and not expired at `now`, in seconds since the Unix epoch.
171///
172/// Returns the proof object, its cryptosuite, and its `verificationMethod` id.
173/// The caller resolves that id to a key of the matching type and passes both
174/// back to [`verify_object_integrity_proof`]. `proof` may be a single object or
175/// an array (only the first usable one is returned; [`integrity_proofs`] has
176/// them all).
177#[must_use]
178pub fn extract_integrity_proof(
179    document: &Value,
180    now: i64,
181) -> Option<(Map<String, Value>, Cryptosuite, String)> {
182    integrity_proofs(document, now).into_iter().next()
183}
184
185/// Every usable assertion-method integrity proof on `document`, in order, as
186/// [`extract_integrity_proof`] returns one. A document may carry several, in
187/// different suites or by different keys, and one that does not verify says
188/// nothing about the others.
189#[must_use]
190pub fn integrity_proofs(
191    document: &Value,
192    now: i64,
193) -> Vec<(Map<String, Value>, Cryptosuite, String)> {
194    let candidates = match document.get("proof") {
195        Some(Value::Array(arr)) => arr.clone(),
196        Some(obj @ Value::Object(_)) => alloc_one(obj.clone()),
197        _ => return Vec::new(),
198    };
199    let mut proofs = Vec::new();
200    for candidate in candidates {
201        let Value::Object(obj) = candidate else {
202            continue;
203        };
204        let Some(suite) = obj
205            .get("cryptosuite")
206            .and_then(Value::as_str)
207            .and_then(Cryptosuite::from_str)
208        else {
209            continue;
210        };
211        let usable = obj.get("proofPurpose").and_then(Value::as_str) == Some("assertionMethod")
212            && obj.get("proofValue").and_then(Value::as_str).is_some()
213            && obj.get("created").and_then(Value::as_str).is_some();
214        if !usable {
215            continue;
216        }
217        // A proof that has expired is not a proof, and the expiry is covered by
218        // the signature, so it cannot have been added by anyone else.
219        if let Some(expires) = obj.get("expires").and_then(Value::as_str) {
220            match chrono::DateTime::parse_from_rfc3339(expires) {
221                Ok(deadline) if deadline.timestamp() < now => continue,
222                Err(_) => continue,
223                Ok(_) => {}
224            }
225        }
226        if let Some(vm) = obj.get("verificationMethod").and_then(Value::as_str) {
227            let vm = vm.to_string();
228            proofs.push((obj, suite, vm));
229        }
230    }
231    proofs
232}
233
234fn alloc_one(value: Value) -> Vec<Value> {
235    vec![value]
236}
237
238/// Verify an integrity `proof` on `document` against `public_key`, the key
239/// resolved from the proof's `verificationMethod`.
240///
241/// # Errors
242/// Returns an error if the proof is malformed, the key does not match the
243/// cryptosuite, or the signature does not verify.
244pub fn verify_object_integrity_proof(
245    document: &Value,
246    proof: &Map<String, Value>,
247    public_key: &PublicKey,
248) -> Result<(), Error> {
249    let suite = proof
250        .get("cryptosuite")
251        .and_then(Value::as_str)
252        .and_then(Cryptosuite::from_str)
253        .ok_or_else(|| Error::Unsupported("or missing cryptosuite".into()))?;
254    let proof_value = proof
255        .get("proofValue")
256        .and_then(Value::as_str)
257        .ok_or_else(|| Error::Malformed("proof has no proofValue".into()))?;
258
259    // The proof configuration is the proof as it was signed: everything the
260    // proof carries except the signature itself. Rebuilding it from a fixed
261    // field list would drop anything else the signer covered — `expires`, or a
262    // suite-specific term — and verification would fail on a valid proof.
263    let mut proof_config = proof.clone();
264    proof_config.remove("proofValue");
265    let document_context = document.get("@context").cloned();
266
267    match suite {
268        Cryptosuite::EddsaJcs2022 => {
269            // The proof may restate `@context` to add vocabulary the suite
270            // needs. It is only honoured when it is a prefix of the document's,
271            // so a signer cannot claim a context the document never had.
272            if let Some(Value::Array(proof_context)) = proof_config.get("@context").cloned() {
273                let Some(Value::Array(doc_context)) = document_context.clone() else {
274                    return Err(Error::Malformed(
275                        "proof declares @context but the document does not".into(),
276                    ));
277                };
278                if doc_context.len() < proof_context.len()
279                    || doc_context[..proof_context.len()] != proof_context[..]
280                {
281                    return Err(Error::Malformed(
282                        "proof @context is not a prefix of the document's".into(),
283                    ));
284                }
285            } else {
286                proof_config.insert(
287                    "@context".to_string(),
288                    document_context.clone().unwrap_or(Value::Null),
289                );
290            }
291        }
292        Cryptosuite::Mldsa44Jcs2024 => {
293            // Deliberately unlike eddsa-jcs-2022, and called out as such by the
294            // specification: the configuration always takes the document's
295            // `@context`, whatever the proof carried.
296            proof_config.insert(
297                "@context".to_string(),
298                document_context.clone().unwrap_or(Value::Null),
299            );
300        }
301    }
302
303    let proof_canon = canonical(&Value::Object(proof_config))?;
304    let proof_hash = Sha256::digest(proof_canon.as_bytes());
305
306    let signature_bytes = decode_multibase(proof_value)?;
307
308    // The document to hash is the activity with every proof form stripped.
309    let mut unsecured = document
310        .as_object()
311        .cloned()
312        .ok_or_else(|| Error::Malformed("document is not a JSON object".into()))?;
313    unsecured.remove("proof");
314    unsecured.remove("https://w3id.org/security#proof");
315
316    // For eddsa-jcs-2022 the signer may have replaced the document's `@context`
317    // with the proof's before canonicalizing; the prefix check above bounds what
318    // that can be.
319    if let (Cryptosuite::EddsaJcs2022, Some(proof_context)) = (suite, proof.get("@context")) {
320        unsecured.insert("@context".to_string(), proof_context.clone());
321    }
322
323    // Candidate documents to hash. The on-wire form is tried first; if the
324    // activity also carries a legacy RsaSignature2017 `signature` (which servers
325    // add *after* the integrity proof, so it isn't covered), retry without it.
326    // Fedify reaches the same document via JSON-LD normalization; stripping the
327    // field directly avoids pulling in a full JSON-LD processor.
328    let mut candidates = vec![unsecured.clone()];
329    if unsecured.contains_key("signature") {
330        let mut without_sig = unsecured;
331        without_sig.remove("signature");
332        candidates.push(without_sig);
333    }
334
335    for candidate in candidates {
336        let doc_canon = match serde_json_canonicalizer::to_string(&Value::Object(candidate)) {
337            Ok(c) => c,
338            Err(_) => continue,
339        };
340        let mut hash_data = [0u8; 64];
341        hash_data[..32].copy_from_slice(&proof_hash);
342        hash_data[32..].copy_from_slice(&Sha256::digest(doc_canon.as_bytes()));
343
344        if verify_signature(suite, public_key, &hash_data, &signature_bytes)? {
345            return Ok(());
346        }
347    }
348    Err(Error::Invalid)
349}
350
351/// `value` as JCS (RFC 8785) writes it.
352fn canonical(value: &Value) -> Result<String, Error> {
353    serde_json_canonicalizer::to_string(value)
354        .map_err(|error| Error::Malformed(format!("cannot canonicalize: {error}")))
355}
356
357/// Generate an Ed25519 signing key, returned as a PKCS#8 PEM.
358///
359/// PEM rather than raw bytes because that is what other implementations expect
360/// to find in a stored private key — Mastodon reads its `keypairs.private_key`
361/// with `OpenSSL::PKey.read` — so a key written here stays readable by a server
362/// pointed at the same database.
363///
364/// # Errors
365/// Returns an error if the key cannot be encoded.
366pub fn generate_ed25519_key(rng: &mut impl CryptoRng) -> Result<String, Error> {
367    use ed25519_dalek::pkcs8::EncodePrivateKey as _;
368
369    let key = ed25519_dalek::SigningKey::generate(rng);
370    Ok(key
371        .to_pkcs8_pem(ed25519_dalek::pkcs8::spki::der::pem::LineEnding::LF)
372        .map_err(|error| Error::Key(format!("Ed25519 key as PKCS#8: {error}")))?
373        .to_string())
374}
375
376/// The raw 32-byte seed and public key of a PKCS#8 PEM Ed25519 key.
377///
378/// # Errors
379/// Returns an error if the PEM is not a PKCS#8 Ed25519 private key.
380pub fn parse_ed25519_key(pem: &str) -> Result<([u8; 32], [u8; 32]), Error> {
381    use ed25519_dalek::pkcs8::DecodePrivateKey as _;
382
383    let key = ed25519_dalek::SigningKey::from_pkcs8_pem(pem)
384        .map_err(|_| Error::Key("not an Ed25519 private key in PKCS#8 PEM".into()))?;
385    Ok((key.to_bytes(), key.verifying_key().to_bytes()))
386}
387
388/// The public key of a raw 32-byte Ed25519 seed.
389#[must_use]
390pub fn ed25519_public_key(seed: &[u8; 32]) -> [u8; 32] {
391    ed25519_dalek::SigningKey::from_bytes(seed)
392        .verifying_key()
393        .to_bytes()
394}
395
396/// A fresh raw 32-byte Ed25519 seed.
397#[must_use]
398pub fn generate_ed25519_seed(rng: &mut impl CryptoRng) -> [u8; 32] {
399    ed25519_dalek::SigningKey::generate(rng).to_bytes()
400}
401
402/// The Data Integrity context that defines `proof`, version 1: what a
403/// document is to carry before a proof is attached to it, for a JSON-LD
404/// reader to find the proof at all.
405pub const DATA_INTEGRITY_CONTEXT: &str = "https://w3id.org/security/data-integrity/v1";
406
407/// `document` with [`DATA_INTEGRITY_CONTEXT`] in its `@context`, however that
408/// is shaped — added to an array, made an array with a single context, or
409/// set when there is none — and as it was if it is there already. `None`
410/// when `document` is not an object.
411///
412/// A proof signs `@context` with the rest, so this comes before
413/// [`sign_object_integrity_proof`], not after.
414#[must_use]
415pub fn with_data_integrity_context(mut document: Value) -> Option<Value> {
416    let object = document.as_object_mut()?;
417    match object.get_mut("@context") {
418        Some(Value::Array(entries)) => {
419            if !entries
420                .iter()
421                .any(|entry| entry.as_str() == Some(DATA_INTEGRITY_CONTEXT))
422            {
423                entries.push(Value::from(DATA_INTEGRITY_CONTEXT));
424            }
425        }
426        Some(Value::String(context)) if context == DATA_INTEGRITY_CONTEXT => {}
427        Some(existing) => {
428            let existing = existing.take();
429            object.insert(
430                "@context".to_string(),
431                Value::Array(vec![existing, Value::from(DATA_INTEGRITY_CONTEXT)]),
432            );
433        }
434        None => {
435            object.insert("@context".to_string(), Value::from(DATA_INTEGRITY_CONTEXT));
436        }
437    }
438    Some(document)
439}
440
441/// Attach an `eddsa-jcs-2022` integrity proof to `document`.
442///
443/// The inverse of [`verify_object_integrity_proof`], and deliberately written
444/// to be verified by it: the proof configuration is hashed, then the document
445/// without its proof, and the Ed25519 signature covers both hashes in that
446/// order.
447///
448/// A proof authenticates the activity rather than the transport that carried
449/// it, which is what lets a relayed or forwarded activity still be attributed.
450/// Mastodon 4.7 verifies these but does not produce them.
451///
452/// # Arguments
453/// * `document`            – the activity to sign; must be a JSON object
454/// * `verification_method` – the id of the key, as published in the actor's
455///   `assertionMethod`
456/// * `signing_key`         – the raw 32-byte Ed25519 seed
457/// * `now`                 – the time, in seconds since the Unix epoch, which
458///   becomes the proof's `created`
459///
460/// # Errors
461/// Returns an error if the document is not an object or cannot be canonicalized.
462pub fn sign_object_integrity_proof(
463    document: &Value,
464    verification_method: &str,
465    signing_key: &[u8; 32],
466    now: i64,
467) -> Result<Value, Error> {
468    use ed25519_dalek::Signer as _;
469
470    let mut unsecured = document
471        .as_object()
472        .cloned()
473        .ok_or_else(|| Error::Malformed("document is not a JSON object".into()))?;
474    unsecured.remove("proof");
475    unsecured.remove("https://w3id.org/security#proof");
476
477    let created = chrono::DateTime::from_timestamp(now, 0)
478        .ok_or_else(|| Error::Malformed(format!("{now} is not a time")))?
479        .format("%Y-%m-%dT%H:%M:%SZ")
480        .to_string();
481
482    // The configuration carries the document's `@context`, as the verifier
483    // reconstructs it. Field order is irrelevant: JCS sorts keys.
484    let mut proof_config = Map::new();
485    proof_config.insert(
486        "@context".to_string(),
487        unsecured.get("@context").cloned().unwrap_or(Value::Null),
488    );
489    proof_config.insert("type".to_string(), Value::from("DataIntegrityProof"));
490    proof_config.insert(
491        "cryptosuite".to_string(),
492        Value::from(Cryptosuite::EddsaJcs2022.as_str()),
493    );
494    proof_config.insert(
495        "verificationMethod".to_string(),
496        Value::from(verification_method),
497    );
498    proof_config.insert("proofPurpose".to_string(), Value::from("assertionMethod"));
499    proof_config.insert("created".to_string(), Value::from(created));
500
501    let proof_canon = canonical(&Value::Object(proof_config.clone()))?;
502    let doc_canon = canonical(&Value::Object(unsecured.clone()))?;
503
504    let mut hash_data = [0u8; 64];
505    hash_data[..32].copy_from_slice(&Sha256::digest(proof_canon.as_bytes()));
506    hash_data[32..].copy_from_slice(&Sha256::digest(doc_canon.as_bytes()));
507
508    let signature = ed25519_dalek::SigningKey::from_bytes(signing_key).sign(&hash_data);
509
510    let mut proof = proof_config;
511    proof.insert(
512        "proofValue".to_string(),
513        Value::from(encode_multibase_base58btc(&signature.to_bytes())),
514    );
515
516    let mut signed = unsecured;
517    signed.insert("proof".to_string(), Value::Object(proof));
518    Ok(Value::Object(signed))
519}
520
521/// The Multikey `publicKeyMultibase` for an Ed25519 public key: the
522/// `ed25519-pub` multicodec header, base58btc, with multibase's `z` prefix.
523#[must_use]
524pub fn encode_ed25519_multikey(public_key: &[u8; 32]) -> String {
525    let mut bytes = ED25519_PUB_MULTICODEC.to_vec();
526    bytes.extend_from_slice(public_key);
527    encode_multibase_base58btc(&bytes)
528}
529
530fn encode_multibase_base58btc(bytes: &[u8]) -> String {
531    format!("z{}", bs58::encode(bytes).into_string())
532}
533
534/// Check one signature, with the key the cryptosuite calls for.
535fn verify_signature(
536    suite: Cryptosuite,
537    public_key: &PublicKey,
538    message: &[u8],
539    signature: &[u8],
540) -> Result<bool, Error> {
541    match (suite, public_key) {
542        (Cryptosuite::EddsaJcs2022, PublicKey::Ed25519(key)) => {
543            let signature = ed25519_dalek::Signature::from_slice(signature)
544                .map_err(|e| Error::Malformed(format!("Ed25519 signature: {e}")))?;
545            let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(key)
546                .map_err(|e| Error::Key(format!("Ed25519 public key: {e}")))?;
547            Ok(verifying_key.verify(message, &signature).is_ok())
548        }
549        (Cryptosuite::Mldsa44Jcs2024, PublicKey::MlDsa44(key)) => {
550            let encoded = ml_dsa::EncodedVerifyingKey::<ml_dsa::MlDsa44>::try_from(&key[..])
551                .map_err(|_| Error::Key(format!("an ML-DSA-44 key of {} bytes", key.len())))?;
552            let verifying_key = ml_dsa::VerifyingKey::<ml_dsa::MlDsa44>::decode(&encoded);
553            let Ok(encoded_signature) =
554                ml_dsa::EncodedSignature::<ml_dsa::MlDsa44>::try_from(signature)
555            else {
556                return Ok(false);
557            };
558            let Some(signature) = ml_dsa::Signature::<ml_dsa::MlDsa44>::decode(&encoded_signature)
559            else {
560                return Ok(false);
561            };
562            // Empty context string, as the cryptosuite specifies.
563            Ok(verifying_key.verify_with_context(message, b"", &signature))
564        }
565        (suite, _) => Err(Error::WrongKey(format!(
566            "key type does not match cryptosuite {}",
567            suite.as_str()
568        ))),
569    }
570}
571
572/// Decode a Multikey `publicKeyMultibase` value into a raw 32-byte Ed25519 key.
573///
574/// # Errors
575/// Returns an error if the value is not a base58btc multibase string wrapping an
576/// `ed25519-pub` multicodec key.
577pub fn decode_ed25519_multikey(multibase: &str) -> Result<[u8; 32], Error> {
578    let bytes = decode_multibase(multibase)?;
579    let key = bytes
580        .strip_prefix(&ED25519_PUB_MULTICODEC)
581        .ok_or_else(|| Error::Key("multikey is not ed25519-pub".into()))?;
582    ed25519_bytes(key)
583}
584
585fn ed25519_bytes(key: &[u8]) -> Result<[u8; 32], Error> {
586    key.try_into()
587        .map_err(|_| Error::Key(format!("an Ed25519 key of {} bytes", key.len())))
588}
589
590/// Decode a Multikey `publicKeyMultibase` value into an ML-DSA-44 public key.
591///
592/// # Errors
593/// Returns an error if the value is not a base58btc multibase string wrapping
594/// an `mldsa-44-pub` multicodec key.
595pub fn decode_mldsa44_multikey(multibase: &str) -> Result<Vec<u8>, Error> {
596    let bytes = decode_multibase(multibase)?;
597    let key = bytes
598        .strip_prefix(&MLDSA44_PUB_MULTICODEC)
599        .ok_or_else(|| Error::Key("multikey is not mldsa-44-pub".into()))?;
600    Ok(key.to_vec())
601}
602
603/// Decode whichever key type a Multikey carries.
604///
605/// # Errors
606/// Returns an error if the value is not a base58btc multibase string wrapping a
607/// key type this module can verify with.
608pub fn decode_multikey(multibase: &str) -> Result<PublicKey, Error> {
609    let bytes = decode_multibase(multibase)?;
610    if let Some(key) = bytes.strip_prefix(&ED25519_PUB_MULTICODEC) {
611        return Ok(PublicKey::Ed25519(Box::new(ed25519_bytes(key)?)));
612    }
613    if let Some(key) = bytes.strip_prefix(&MLDSA44_PUB_MULTICODEC) {
614        return Ok(PublicKey::MlDsa44(key.to_vec().into_boxed_slice()));
615    }
616    Err(Error::Unsupported("multikey type".into()))
617}
618
619/// Decode a multibase value into bytes.
620///
621/// Multibase names its own encoding in the first character. Two appear in this
622/// corner of the fediverse: `z` (base58btc), which Fedify and Mastodon write,
623/// and `u` (base64url, unpadded), which the W3C quantum-resistant cryptosuite
624/// examples use.
625fn decode_multibase(value: &str) -> Result<Vec<u8>, Error> {
626    let mut chars = value.chars();
627    match chars.next() {
628        Some('z') => bs58::decode(chars.as_str())
629            .into_vec()
630            .map_err(|e| Error::Malformed(format!("base58btc: {e}"))),
631        Some('u') => base64::Engine::decode(
632            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
633            chars.as_str(),
634        )
635        .map_err(|e| Error::Malformed(format!("base64url: {e}"))),
636        Some(other) => Err(Error::Unsupported(format!("multibase encoding {other:?}"))),
637        None => Err(Error::Malformed("empty multibase value".into())),
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn the_data_integrity_context_is_added_however_context_is_shaped() {
647        use serde_json::json;
648        let as_ = "https://www.w3.org/ns/activitystreams";
649        assert_eq!(
650            with_data_integrity_context(json!({"@context": as_})).unwrap()["@context"],
651            json!([as_, DATA_INTEGRITY_CONTEXT])
652        );
653        assert_eq!(
654            with_data_integrity_context(json!({"@context": [as_, {"a": "b"}]})).unwrap()["@context"],
655            json!([as_, {"a": "b"}, DATA_INTEGRITY_CONTEXT])
656        );
657        let already = json!({"@context": [as_, DATA_INTEGRITY_CONTEXT]});
658        assert_eq!(
659            with_data_integrity_context(already.clone()).unwrap(),
660            already
661        );
662        assert_eq!(
663            with_data_integrity_context(json!({})).unwrap()["@context"],
664            json!(DATA_INTEGRITY_CONTEXT)
665        );
666        assert_eq!(with_data_integrity_context(json!("x")), None);
667    }
668
669    /// RFC 8410 §10.1's example public key.
670    #[test]
671    fn reads_and_writes_the_rfc_8410_public_key() {
672        let pem = "-----BEGIN PUBLIC KEY-----\n\
673                   MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
674                   -----END PUBLIC KEY-----\n";
675        let key = PublicKey::from_spki_pem(pem).unwrap();
676        let PublicKey::Ed25519(raw) = &key else {
677            panic!("{key:?}");
678        };
679        assert_eq!(raw[..4], [0x19, 0xbf, 0x44, 0x09]);
680        assert_eq!(key.to_spki_pem().unwrap(), pem);
681    }
682
683    /// An ML-DSA-44 key is wrapped in lines of 64, as OpenSSL writes it, and
684    /// read back however it is wrapped.
685    #[test]
686    fn an_ml_dsa_44_key_round_trips_through_pem() {
687        let key = PublicKey::MlDsa44(vec![7; 1312].into_boxed_slice());
688        let pem = key.to_spki_pem().unwrap();
689        assert!(pem.starts_with("-----BEGIN PUBLIC KEY-----\nMIIFMjALBglghkgBZQMEAxEDggUhAA"));
690        assert!(pem.lines().all(|line| line.len() <= 64));
691        assert_eq!(PublicKey::from_spki_pem(&pem).unwrap(), key);
692        let unwrapped: String = pem.lines().collect::<Vec<_>>().join("");
693        let unwrapped = unwrapped
694            .replace("-----BEGIN PUBLIC KEY-----", "-----BEGIN PUBLIC KEY-----\n")
695            .replace("-----END", "\n-----END");
696        assert_eq!(PublicKey::from_spki_pem(&unwrapped).unwrap(), key);
697        assert!(
698            PublicKey::MlDsa44(vec![7; 10].into_boxed_slice())
699                .to_spki_pem()
700                .is_err()
701        );
702    }
703
704    #[test]
705    fn what_is_not_an_ed25519_or_ml_dsa_44_key_is_refused() {
706        let rsa = include_str!("../../ojak/tests/fixtures/rfc9421_test_key_rsa_public.pem");
707        assert!(PublicKey::from_spki_pem(rsa).is_err());
708        assert!(PublicKey::from_spki_pem("not base64!").is_err());
709    }
710
711    /// When the tests read and sign proofs: after the W3C vectors were
712    /// made, and before the far expiry `ignores_an_expired_proof` sets.
713    const NOW: i64 = 1_759_000_000;
714
715    /// FEP-8b32's own test vector (fep-8b32.feature): a proof that restates
716    /// the document's `@context`, over a document with decimals, which JCS
717    /// has to write as ECMAScript does.
718    #[test]
719    fn verifies_the_fep_8b32_test_vector() {
720        let document = serde_json::json!({
721            "@context": [
722                "https://www.w3.org/ns/activitystreams",
723                "https://w3id.org/security/data-integrity/v2"
724            ],
725            "id": "https://server.example/activities/1",
726            "type": "Create",
727            "actor": "https://server.example/users/alice",
728            "object": {
729                "id": "https://server.example/objects/1",
730                "type": "Note",
731                "attributedTo": "https://server.example/users/alice",
732                "content": "Hello world",
733                "location": {
734                    "type": "Place",
735                    "longitude": -71.184902,
736                    "latitude": 25.273962
737                }
738            },
739            "proof": {
740                "@context": [
741                    "https://www.w3.org/ns/activitystreams",
742                    "https://w3id.org/security/data-integrity/v2"
743                ],
744                "type": "DataIntegrityProof",
745                "cryptosuite": "eddsa-jcs-2022",
746                "verificationMethod": "https://server.example/users/alice#ed25519-key",
747                "proofPurpose": "assertionMethod",
748                "proofValue": "z42ffGu6AUKPCFcFPiabmUvnGLPJzC7e4DGWC52NUasSSH37UMa9c58tdgVszUcZfytxa4fQ5TYHaJENCxUDe9SdL",
749                "created": "2023-02-24T23:36:38Z"
750            }
751        });
752        let key = decode_multikey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2").unwrap();
753        let (proof, suite, method) = extract_integrity_proof(&document, NOW).unwrap();
754        assert_eq!(suite, Cryptosuite::EddsaJcs2022);
755        assert_eq!(method, "https://server.example/users/alice#ed25519-key");
756        verify_object_integrity_proof(&document, &proof, &key).unwrap();
757
758        let mut moved = document.clone();
759        moved["object"]["location"]["latitude"] = serde_json::json!(25.273963);
760        assert!(verify_object_integrity_proof(&moved, &proof, &key).is_err());
761    }
762
763    /// RFC 8785 §3.2.3: keys are sorted by their UTF-16 code units, which is
764    /// how a JavaScript signer sorts them, not by their UTF-8 bytes, and
765    /// unescaped. Sorted by bytes, U+FB33 would come before the emoji and
766    /// `\r` after `1`.
767    #[test]
768    fn canonical_json_sorts_keys_as_rfc_8785_does() {
769        let object = serde_json::json!({
770            "\u{20ac}": "Euro Sign",
771            "\r": "Carriage Return",
772            "\u{fb33}": "Hebrew Letter Dalet With Dagesh",
773            "1": "One",
774            "\u{1f600}": "Emoji: Grinning Face",
775            "\u{80}": "Control",
776            "\u{f6}": "Latin Small Letter O With Diaeresis"
777        });
778        let canonical = serde_json_canonicalizer::to_string(&object).unwrap();
779        let order: Vec<&str> = [
780            "Carriage Return",
781            "One",
782            "Control",
783            "Latin Small Letter O With Diaeresis",
784            "Euro Sign",
785            "Emoji: Grinning Face",
786            "Hebrew Letter Dalet With Dagesh",
787        ]
788        .into_iter()
789        .collect();
790        let mut positions: Vec<usize> = order
791            .iter()
792            .map(|value| canonical.find(value).unwrap())
793            .collect();
794        let sorted = {
795            let mut sorted = positions.clone();
796            sorted.sort_unstable();
797            sorted
798        };
799        assert_eq!(positions, sorted, "{canonical}");
800        positions.dedup();
801        assert_eq!(positions.len(), order.len());
802    }
803
804    // Worked example from the W3C "Data Integrity EdDSA Cryptosuites"
805    // specification (§ "Representation: eddsa-jcs-2022"). Verifying it end to end
806    // exercises JCS canonicalization, the SHA-256 hash ordering, multibase
807    // decoding, and Ed25519 verification against a spec-authoritative vector —
808    // the same algorithm Fedify (hence hackers.pub) uses to sign.
809    fn w3c_signed_credential() -> Value {
810        serde_json::json!({
811          "@context": [
812            "https://www.w3.org/ns/credentials/v2",
813            "https://www.w3.org/ns/credentials/examples/v2"
814          ],
815          "id": "urn:uuid:58172aac-d8ba-11ed-83dd-0b3aef56cc33",
816          "type": ["VerifiableCredential", "AlumniCredential"],
817          "name": "Alumni Credential",
818          "description": "A minimum viable example of an Alumni Credential.",
819          "issuer": "https://vc.example/issuers/5678",
820          "validFrom": "2023-01-01T00:00:00Z",
821          "credentialSubject": {
822            "id": "did:example:abcdefgh",
823            "alumniOf": "The School of Examples"
824          },
825          "proof": {
826            "type": "DataIntegrityProof",
827            "cryptosuite": "eddsa-jcs-2022",
828            "created": "2023-02-24T23:36:38Z",
829            "verificationMethod": "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2#z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2",
830            "proofPurpose": "assertionMethod",
831            "@context": [
832              "https://www.w3.org/ns/credentials/v2",
833              "https://www.w3.org/ns/credentials/examples/v2"
834            ],
835            "proofValue": "z2HnFSSPPBzR36zdDgK8PbEHeXbR56YF24jwMpt3R1eHXQzJDMWS93FCzpvJpwTWd3GAVFuUfjoJdcnTMuVor51aX"
836          }
837        })
838    }
839
840    #[test]
841    fn verifies_w3c_eddsa_jcs_2022_vector() {
842        let doc = w3c_signed_credential();
843        let (proof, suite, vm) = extract_integrity_proof(&doc, NOW).expect("proof present");
844        assert_eq!(suite, Cryptosuite::EddsaJcs2022);
845        assert!(vm.starts_with("did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2"));
846
847        // The verification method's fragment is the publicKeyMultibase.
848        let key = decode_multikey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2")
849            .expect("decode multikey");
850
851        verify_object_integrity_proof(&doc, &proof, &key).expect("valid proof must verify");
852    }
853
854    #[test]
855    fn rejects_tampered_document() {
856        let mut doc = w3c_signed_credential();
857        doc["issuer"] = serde_json::json!("https://vc.example/issuers/evil");
858        let (proof, _, _) = extract_integrity_proof(&doc, NOW).expect("proof present");
859        let key = decode_multikey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2")
860            .expect("decode multikey");
861        assert!(verify_object_integrity_proof(&doc, &proof, &key).is_err());
862    }
863
864    /// Worked example from the W3C "Verifiable Credential Data Integrity —
865    /// Quantum Resistant Cryptosuites" specification (Example 22, signed
866    /// credential `mldsa44-jcs-2024`). It exercises the parts that differ from
867    /// the Ed25519 suite: the `mldsa-44-pub` multicodec, base64url multibase,
868    /// the proof configuration always taking the document's `@context`, and
869    /// ML-DSA-44 verification itself.
870    #[test]
871    fn verifies_w3c_mldsa44_jcs_2024_vector() {
872        let doc: Value =
873            serde_json::from_str(include_str!("../tests/fixtures/w3c_mldsa44_jcs_2024.json"))
874                .expect("parse vector");
875
876        let (proof, suite, vm) = extract_integrity_proof(&doc, NOW).expect("proof present");
877        assert_eq!(suite, Cryptosuite::Mldsa44Jcs2024);
878
879        // did:key:<multibase>#<multibase> — the key is the method-specific id.
880        let multibase = vm
881            .strip_prefix("did:key:")
882            .and_then(|rest| rest.split('#').next())
883            .expect("did:key verification method");
884        let key = decode_multikey(multibase).expect("decode multikey");
885        assert!(
886            matches!(&key, PublicKey::MlDsa44(bytes) if bytes.len() == 1312),
887            "expected a 1312-byte ML-DSA-44 key"
888        );
889
890        verify_object_integrity_proof(&doc, &proof, &key).expect("valid proof must verify");
891    }
892
893    #[test]
894    fn rejects_a_tampered_mldsa44_document() {
895        let mut doc: Value =
896            serde_json::from_str(include_str!("../tests/fixtures/w3c_mldsa44_jcs_2024.json"))
897                .expect("parse vector");
898        doc["issuer"] = serde_json::json!("did:example:evil");
899
900        let (proof, _, vm) = extract_integrity_proof(&doc, NOW).expect("proof present");
901        let multibase = vm
902            .strip_prefix("did:key:")
903            .and_then(|rest| rest.split('#').next())
904            .unwrap();
905        let key = decode_multikey(multibase).unwrap();
906        assert!(verify_object_integrity_proof(&doc, &proof, &key).is_err());
907    }
908
909    /// An Ed25519 key cannot stand in for an ML-DSA proof, or the other way
910    /// round: the cryptosuite names the key type, and a mismatch is an error
911    /// rather than a verification failure to retry.
912    #[test]
913    fn refuses_a_key_of_the_wrong_type() {
914        let doc: Value =
915            serde_json::from_str(include_str!("../tests/fixtures/w3c_mldsa44_jcs_2024.json"))
916                .expect("parse vector");
917        let (proof, _, _) = extract_integrity_proof(&doc, NOW).expect("proof present");
918
919        let ed25519 = decode_multikey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2").unwrap();
920        let err = verify_object_integrity_proof(&doc, &proof, &ed25519).unwrap_err();
921        assert!(matches!(err, Error::WrongKey(_)), "{err}");
922    }
923
924    #[test]
925    fn decodes_both_multibase_encodings() {
926        // "hello" in base58btc and base64url, as multibase writes them.
927        assert_eq!(decode_multibase("zCn8eVZg").unwrap(), b"hello");
928        assert_eq!(decode_multibase("uaGVsbG8").unwrap(), b"hello");
929        assert!(decode_multibase("Qhello").is_err());
930        assert!(decode_multibase("").is_err());
931    }
932
933    #[test]
934    fn ignores_an_expired_proof() {
935        let mut doc = w3c_signed_credential();
936        doc["proof"]["expires"] = serde_json::json!("2020-01-01T00:00:00Z");
937        assert!(
938            extract_integrity_proof(&doc, NOW).is_none(),
939            "an expired proof must not be offered for verification"
940        );
941
942        doc["proof"]["expires"] = serde_json::json!("2999-01-01T00:00:00Z");
943        assert!(extract_integrity_proof(&doc, NOW).is_some());
944    }
945
946    /// A proof this module produces is one it accepts. Round-tripping is weak
947    /// evidence on its own, but the verifier is itself pinned to the W3C
948    /// vector, so agreeing with it means agreeing with the specification.
949    #[test]
950    fn signs_a_proof_its_own_verifier_accepts() {
951        use ed25519_dalek::SigningKey;
952
953        let seed = [7u8; 32];
954        let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes();
955        let multikey = encode_ed25519_multikey(&public);
956
957        let document = serde_json::json!({
958            "@context": "https://www.w3.org/ns/activitystreams",
959            "id": "https://local.example/activities/1",
960            "type": "Create",
961            "actor": "https://local.example/users/alice",
962            "object": {"id": "https://local.example/notes/1", "type": "Note", "content": "hi"},
963        });
964
965        let vm = "https://local.example/users/alice#ed25519-key";
966        let signed = sign_object_integrity_proof(&document, vm, &seed, NOW).unwrap();
967
968        let (proof, suite, found_vm) =
969            extract_integrity_proof(&signed, NOW).expect("proof attached");
970        assert_eq!(suite, Cryptosuite::EddsaJcs2022);
971        assert_eq!(found_vm, vm);
972
973        let key = decode_multikey(&multikey).unwrap();
974        verify_object_integrity_proof(&signed, &proof, &key).expect("own proof must verify");
975    }
976
977    /// The proof covers the document: change anything under it and the proof
978    /// stops holding.
979    #[test]
980    fn a_signed_document_cannot_be_altered() {
981        use ed25519_dalek::SigningKey;
982
983        let seed = [9u8; 32];
984        let public = SigningKey::from_bytes(&seed).verifying_key().to_bytes();
985        let multikey = encode_ed25519_multikey(&public);
986
987        let document = serde_json::json!({
988            "@context": "https://www.w3.org/ns/activitystreams",
989            "id": "https://local.example/activities/2",
990            "type": "Create",
991            "actor": "https://local.example/users/alice",
992        });
993        let mut signed = sign_object_integrity_proof(
994            &document,
995            "https://local.example/users/alice#k",
996            &seed,
997            NOW,
998        )
999        .unwrap();
1000        signed["actor"] = serde_json::json!("https://local.example/users/mallory");
1001
1002        let (proof, _, _) = extract_integrity_proof(&signed, NOW).unwrap();
1003        let key = decode_multikey(&multikey).unwrap();
1004        assert!(verify_object_integrity_proof(&signed, &proof, &key).is_err());
1005    }
1006
1007    /// The published key decodes back to the bytes it was made from, with the
1008    /// multicodec header the W3C vector uses.
1009    /// A generated key is a PKCS#8 PEM — the form other implementations read
1010    /// a stored private key from — and parses back to the bytes that sign.
1011    #[test]
1012    fn generates_a_pem_key_that_parses_back() {
1013        let pem = generate_ed25519_key(&mut rand::rng()).unwrap();
1014        assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
1015
1016        let (seed, public) = parse_ed25519_key(&pem).unwrap();
1017        assert_eq!(
1018            ed25519_dalek::SigningKey::from_bytes(&seed)
1019                .verifying_key()
1020                .to_bytes(),
1021            public,
1022            "the public half must belong to the seed"
1023        );
1024
1025        // And it is usable end to end.
1026        let document = serde_json::json!({
1027            "@context": "https://www.w3.org/ns/activitystreams",
1028            "type": "Create",
1029        });
1030        let signed =
1031            sign_object_integrity_proof(&document, "https://x.test/a#k", &seed, NOW).unwrap();
1032        let (proof, _, _) = extract_integrity_proof(&signed, NOW).unwrap();
1033        let key = decode_multikey(&encode_ed25519_multikey(&public)).unwrap();
1034        verify_object_integrity_proof(&signed, &proof, &key).unwrap();
1035
1036        assert_ne!(
1037            pem,
1038            generate_ed25519_key(&mut rand::rng()).unwrap(),
1039            "keys must differ"
1040        );
1041    }
1042
1043    #[test]
1044    fn a_published_multikey_round_trips() {
1045        let public = [3u8; 32];
1046        let multikey = encode_ed25519_multikey(&public);
1047        assert!(multikey.starts_with('z'));
1048        assert_eq!(decode_ed25519_multikey(&multikey).unwrap(), public);
1049
1050        match decode_multikey(&multikey).unwrap() {
1051            PublicKey::Ed25519(bytes) => assert_eq!(*bytes, public),
1052            PublicKey::MlDsa44(_) => panic!("decoded as the wrong key type"),
1053        }
1054    }
1055
1056    #[test]
1057    fn missing_proof_is_none() {
1058        let doc = serde_json::json!({ "type": "Note", "content": "hi" });
1059        assert!(extract_integrity_proof(&doc, NOW).is_none());
1060    }
1061}