Skip to main content

ojak_sig/
linked_data.rs

1//! Linked Data Signatures: `RsaSignature2017`, as Mastodon makes and checks
2//! them (`ActivityPub::LinkedDataSignature`).
3//!
4//! The signature sits in the document it signs, under `signature`, and covers
5//! two SHA-256 hashes: one of the signature's own options (`creator`,
6//! `created`, `expires`) read against the identity context, and one of the
7//! document without its `signature`. Each hash is of the canonical N-Quads
8//! of what the JSON-LD means ([`ojak_jsonld::rdf`]), so a server that passes
9//! the document on — a relay, or a server forwarding a reply — cannot change
10//! what it says without the signature failing, while the signature still says
11//! who wrote it.
12//!
13//! That cuts both ways. What verifies is the graph, not the tree: a document
14//! can be rewritten into one that reads differently to a reader of JSON keys
15//! and still verify. A caller that takes a document on the strength of this
16//! signature should read it as JSON-LD processing gives it, not as it was
17//! written; ojak's inbox does.
18
19use alloc::{
20    format,
21    string::{String, ToString},
22    vec::Vec,
23};
24use ojak_jsonld::Registry;
25use serde_json::{Map, Value};
26use sha2::{Digest as _, Sha256};
27
28use crate::Error;
29use crate::signature::{PrivateKey, rsa_verify_pkcs1v15};
30
31/// The one Linked Data Signature suite the fediverse uses.
32pub const RSA_SIGNATURE_2017: &str = "RsaSignature2017";
33
34/// What the signature's options are read against.
35pub const IDENTITY_CONTEXT: &str = "https://w3id.org/identity/v1";
36
37/// What a signed document's `@context` gains, so that `signature` means
38/// something to a JSON-LD reader.
39pub const SECURITY_CONTEXT: &str = "https://w3id.org/security/v1";
40
41/// How long Mastodon's signatures last.
42pub const DEFAULT_LIFETIME_SECONDS: i64 = 2 * 24 * 60 * 60;
43
44/// The key an `RsaSignature2017` on `document` says made it: its `creator`.
45///
46/// Says nothing about whether it verifies.
47#[must_use]
48pub fn creator(document: &Value) -> Option<&str> {
49    let signature = document.get("signature")?;
50    if signature.get("type").and_then(Value::as_str) != Some(RSA_SIGNATURE_2017) {
51        return None;
52    }
53    signature.get("creator").and_then(Value::as_str)
54}
55
56/// Sign `document` as `key_id`, which `private_key` is the key of: the
57/// document with an `RsaSignature2017` made at `created` and good until
58/// `expires` (Unix seconds), and the security context added to its
59/// `@context`, as `ActivityPub::LinkedDataSignature#sign!` gives it.
60///
61/// A `signature` the document already carries is replaced.
62///
63/// # Errors
64///
65/// When the document, or the options, cannot be turned into RDF: a context
66/// ojak does not ship, or a JSON-LD feature ojak does not turn into RDF.
67pub fn sign(
68    registry: &Registry,
69    document: &Value,
70    key_id: &str,
71    private_key: &PrivateKey,
72    created: i64,
73    expires: i64,
74) -> Result<Value, Error> {
75    let Value::Object(members) = document else {
76        return Err(Error::Malformed(
77            "a document to sign is a JSON object".into(),
78        ));
79    };
80    let mut options = Map::new();
81    options.insert("type".into(), Value::from(RSA_SIGNATURE_2017));
82    options.insert("creator".into(), Value::from(key_id));
83    options.insert("created".into(), Value::from(timestamp(created)?));
84    options.insert("expires".into(), Value::from(timestamp(expires)?));
85
86    let message = message(registry, &options, members)?;
87    let signature_value = private_key.sign(message.as_bytes());
88    options.insert("signatureValue".into(), Value::from(signature_value));
89
90    let mut signed = members.clone();
91    signed.insert("signature".into(), Value::Object(options));
92    signed.insert(
93        "@context".into(),
94        with_security_context(members.get("@context")),
95    );
96    Ok(Value::Object(signed))
97}
98
99/// Check the `RsaSignature2017` on `document` against `public_key_pem`, the
100/// key its [`creator`] names, at `now` (Unix seconds).
101///
102/// The caller has found that key, and knows whose it is; the signature says
103/// that its holder signed this document, not who the holder is.
104///
105/// # Errors
106///
107/// [`Error::Missing`] when there is no `RsaSignature2017`,
108/// [`Error::Invalid`] when it has expired or does not verify, and
109/// otherwise why the document could not be read.
110pub fn verify(
111    registry: &Registry,
112    document: &Value,
113    public_key_pem: &str,
114    now: i64,
115) -> Result<(), Error> {
116    Signed::read(registry, document, now)?.verify(public_key_pem)
117}
118
119/// An `RsaSignature2017` read and hashed, its key not yet tried.
120///
121/// Reading is the expensive half — both hashes are of canonical RDF — so a
122/// caller trying more than one copy of a key, a stored one and then a fresh
123/// one, reads once.
124#[derive(Clone, Debug)]
125pub struct Signed {
126    creator: String,
127    message: String,
128    signature_value: String,
129}
130
131impl Signed {
132    /// Read the signature on `document`, refusing it at `now` (Unix seconds)
133    /// if it has expired.
134    ///
135    /// # Errors
136    ///
137    /// As [`verify`], short of trying the key.
138    pub fn read(registry: &Registry, document: &Value, now: i64) -> Result<Self, Error> {
139        let Value::Object(members) = document else {
140            return Err(Error::Malformed(
141                "a signed document is a JSON object".into(),
142            ));
143        };
144        let Some(Value::Object(signature)) = members.get("signature") else {
145            return Err(Error::Missing("signature".into()));
146        };
147        if signature.get("type").and_then(Value::as_str) != Some(RSA_SIGNATURE_2017) {
148            return Err(Error::Unsupported("Linked Data Signature type".into()));
149        }
150        let Some(creator) = signature.get("creator").and_then(Value::as_str) else {
151            return Err(Error::Missing("creator".into()));
152        };
153        let Some(signature_value) = signature.get("signatureValue").and_then(Value::as_str) else {
154            return Err(Error::Missing("signatureValue".into()));
155        };
156        if let Some(expires) = signature.get("expires") {
157            let expires = expires
158                .as_str()
159                .and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
160                .ok_or_else(|| Error::Malformed("expires".into()))?;
161            if expires.timestamp() < now {
162                return Err(Error::Invalid);
163            }
164        }
165        let mut options = signature.clone();
166        for key in ["type", "id", "signatureValue"] {
167            options.remove(key);
168        }
169        Ok(Self {
170            creator: creator.into(),
171            message: message(registry, &options, members)?,
172            // Mastodon decodes leniently (`Base64.decode64`), skipping line
173            // breaks.
174            signature_value: signature_value
175                .chars()
176                .filter(|c| !c.is_ascii_whitespace())
177                .collect(),
178        })
179    }
180
181    /// The key the signature says made it.
182    #[must_use]
183    pub fn creator(&self) -> &str {
184        &self.creator
185    }
186
187    /// Whether the key `public_key_pem` made the signature.
188    ///
189    /// # Errors
190    ///
191    /// [`Error::Key`] for a key that is not an RSA public key, and
192    /// [`Error::Invalid`] when it did not.
193    pub fn verify(&self, public_key_pem: &str) -> Result<(), Error> {
194        rsa_verify_pkcs1v15(
195            public_key_pem,
196            self.message.as_bytes(),
197            &self.signature_value,
198        )
199        .map_err(|error| match error {
200            Error::Key(_) => error,
201            _ => Error::Invalid,
202        })
203    }
204}
205
206/// What is signed: the hex SHA-256 of the options' canonical form, then of
207/// the document's without its `signature`.
208fn message(
209    registry: &Registry,
210    options: &Map<String, Value>,
211    document: &Map<String, Value>,
212) -> Result<String, Error> {
213    let mut options = options.clone();
214    options.insert("@context".into(), Value::from(IDENTITY_CONTEXT));
215    let mut document = document.clone();
216    document.remove("signature");
217    let mut message = hash(registry, &Value::Object(options))?;
218    message.push_str(&hash(registry, &Value::Object(document))?);
219    Ok(message)
220}
221
222fn hash(registry: &Registry, document: &Value) -> Result<String, Error> {
223    let canonical = ojak_jsonld::rdf::canonize(registry, document)
224        .map_err(|error| Error::Unsupported(format!("JSON-LD: {error}")))?;
225    let digest = Sha256::digest(canonical.as_bytes());
226    let mut hex = String::with_capacity(64);
227    for byte in digest {
228        hex.push_str(&format!("{byte:02x}"));
229    }
230    Ok(hex)
231}
232
233/// `Time#iso8601` in UTC: whole seconds, `Z`.
234fn timestamp(seconds: i64) -> Result<String, Error> {
235    chrono::DateTime::from_timestamp(seconds, 0)
236        .map(|time| time.format("%Y-%m-%dT%H:%M:%SZ").to_string())
237        .ok_or_else(|| Error::Malformed("a time out of range".into()))
238}
239
240/// `@context` with the security context added once at the end, a single
241/// entry written as itself.
242fn with_security_context(context: Option<&Value>) -> Value {
243    let mut entries: Vec<Value> = match context {
244        None | Some(Value::Null) => Vec::new(),
245        Some(Value::Array(entries)) => entries.clone(),
246        Some(single) => Vec::from([single.clone()]),
247    };
248    entries.push(Value::from(SECURITY_CONTEXT));
249    let mut unique: Vec<Value> = Vec::with_capacity(entries.len());
250    for entry in entries {
251        if !unique.contains(&entry) {
252            unique.push(entry);
253        }
254    }
255    if unique.len() == 1 {
256        unique.pop().unwrap_or(Value::Null)
257    } else {
258        Value::Array(unique)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use serde_json::json;
266
267    #[test]
268    fn the_security_context_is_added_once() {
269        assert_eq!(
270            with_security_context(Some(&json!("https://www.w3.org/ns/activitystreams"))),
271            json!(["https://www.w3.org/ns/activitystreams", SECURITY_CONTEXT])
272        );
273        assert_eq!(
274            with_security_context(Some(&json!([
275                "https://www.w3.org/ns/activitystreams",
276                SECURITY_CONTEXT
277            ]))),
278            json!(["https://www.w3.org/ns/activitystreams", SECURITY_CONTEXT])
279        );
280        assert_eq!(with_security_context(None), json!(SECURITY_CONTEXT));
281    }
282
283    #[test]
284    fn times_are_written_as_mastodon_writes_them() {
285        assert_eq!(timestamp(0).unwrap(), "1970-01-01T00:00:00Z");
286    }
287}