1use 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
31pub const RSA_SIGNATURE_2017: &str = "RsaSignature2017";
33
34pub const IDENTITY_CONTEXT: &str = "https://w3id.org/identity/v1";
36
37pub const SECURITY_CONTEXT: &str = "https://w3id.org/security/v1";
40
41pub const DEFAULT_LIFETIME_SECONDS: i64 = 2 * 24 * 60 * 60;
43
44#[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
56pub 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
99pub 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#[derive(Clone, Debug)]
125pub struct Signed {
126 creator: String,
127 message: String,
128 signature_value: String,
129}
130
131impl Signed {
132 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 signature_value: signature_value
175 .chars()
176 .filter(|c| !c.is_ascii_whitespace())
177 .collect(),
178 })
179 }
180
181 #[must_use]
183 pub fn creator(&self) -> &str {
184 &self.creator
185 }
186
187 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
206fn 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
233fn 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
240fn 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}