diff --git a/ruma-signatures/.builds/beta.yml b/ruma-signatures/.builds/beta.yml new file mode 100644 index 00000000..e9349ff7 --- /dev/null +++ b/ruma-signatures/.builds/beta.yml @@ -0,0 +1,27 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-signatures +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install beta --profile minimal -c rustfmt -c clippy + rustup default beta + - test: | + cd ruma-signatures + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) diff --git a/ruma-signatures/.builds/nightly.yml b/ruma-signatures/.builds/nightly.yml new file mode 100644 index 00000000..f2345792 --- /dev/null +++ b/ruma-signatures/.builds/nightly.yml @@ -0,0 +1,32 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-signatures +tasks: + - rustup: | + rustup toolchain install nightly --profile minimal + rustup default nightly + + # Try installing rustfmt & clippy for nightly, but don't fail the build + # if they are not available + rustup component add rustfmt || true + rustup component add clippy || true + - test: | + cd ruma-signatures + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + if ( rustup component list | grep -q rustfmt ); then + cargo fmt -- --check + fi + fmt_exit=$? + + if ( rustup component list | grep -q clippy ); then + cargo clippy --all-targets --all-features -- -D warnings + fi + clippy_exit=$? + + exit $(( $fmt_exit || $clippy_exit )) diff --git a/ruma-signatures/.builds/stable.yml b/ruma-signatures/.builds/stable.yml new file mode 100644 index 00000000..8d0152dc --- /dev/null +++ b/ruma-signatures/.builds/stable.yml @@ -0,0 +1,29 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-signatures +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install stable --profile minimal -c rustfmt -c clippy + rustup default stable + - test: | + cd ruma-signatures + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) + # TODO: Add audit task once cargo-audit binary releases are available. + # See https://github.com/RustSec/cargo-audit/issues/66 diff --git a/ruma-signatures/.gitignore b/ruma-signatures/.gitignore new file mode 100644 index 00000000..fa8d85ac --- /dev/null +++ b/ruma-signatures/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +target diff --git a/ruma-signatures/Cargo.toml b/ruma-signatures/Cargo.toml new file mode 100644 index 00000000..21c7fbb9 --- /dev/null +++ b/ruma-signatures/Cargo.toml @@ -0,0 +1,19 @@ +[package] +authors = ["Jimmy Cuadra "] +categories = ["api-bindings", "cryptography"] +description = "Digital signatures according to the Matrix specification." +documentation = "https://docs.rs/ruma-signatures" +edition = "2018" +homepage = "https://github.com/ruma/ruma-signatures" +keywords = ["matrix", "chat", "messaging", "ruma", "cryptography"] +license = "MIT" +name = "ruma-signatures" +readme = "README.md" +repository = "https://github.com/ruma/ruma-signatures" +version = "0.6.0-dev.1" + +[dependencies] +base64 = "0.12.0" +ring = "0.16.12" +serde_json = "1.0.50" +untrusted = "0.7.0" diff --git a/ruma-signatures/LICENSE b/ruma-signatures/LICENSE new file mode 100644 index 00000000..825ea9f9 --- /dev/null +++ b/ruma-signatures/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2015 Jimmy Cuadra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/ruma-signatures/README.md b/ruma-signatures/README.md new file mode 100644 index 00000000..720dd575 --- /dev/null +++ b/ruma-signatures/README.md @@ -0,0 +1,19 @@ +# ruma-signatures + +[![crates.io page](https://img.shields.io/crates/v/ruma-signatures.svg)](https://crates.io/crates/ruma-signatures) +[![docs.rs page](https://docs.rs/ruma-signatures/badge.svg)](https://docs.rs/ruma-signatures/) +![license: MIT](https://img.shields.io/crates/l/ruma-signatures.svg) + +ruma-signatures provides functionality for creating digital signatures according to the [Matrix](https://matrix.org/) specification. + +## Documentation + +ruma-signatures has [comprehensive documentation](https://docs.rs/ruma-signatures) available on docs.rs. + +## Minimum Rust version + +ruma-client-api is only guaranteed to work on the latest stable version of Rust. + +This support policy is inherited from the dependency on [ring][]. + +[ring]: https://github.com/briansmith/ring/ diff --git a/ruma-signatures/src/functions.rs b/ruma-signatures/src/functions.rs new file mode 100644 index 00000000..1f18c23d --- /dev/null +++ b/ruma-signatures/src/functions.rs @@ -0,0 +1,764 @@ +//! Functions for signing and verifying JSON and events. + +use std::collections::HashMap; + +use base64::{decode_config, encode_config, STANDARD_NO_PAD}; +use ring::digest::{digest, SHA256}; +use serde_json::{from_str, from_value, map::Map, to_string, to_value, Value}; + +use crate::{ + keys::{KeyPair, PublicKeyMap}, + signatures::SignatureMap, + split_id, + verification::{Ed25519Verifier, Verified, Verifier}, + Error, +}; + +/// The fields that are allowed to remain in an event during redaction. +static ALLOWED_KEYS: &[&str] = &[ + "event_id", + "type", + "room_id", + "sender", + "state_key", + "content", + "hashes", + "signatures", + "depth", + "prev_events", + "prev_state", + "auth_events", + "origin", + "origin_server_ts", + "membership", +]; + +/// The fields of an *m.room.power_levels* event's `content` key that are allowed to remain in an +/// event during redaction. +static ALLOWED_POWER_LEVELS_KEYS: &[&str] = &[ + "ban", + "events", + "events_default", + "kick", + "redact", + "state_default", + "users", + "users_default", +]; + +/// The fields to remove from a JSON object when converting JSON into the "canonical" form. +static CANONICAL_JSON_FIELDS_TO_REMOVE: &[&str] = &["signatures", "unsigned"]; + +/// The fields to remove from a JSON object when creating a content hash of an event. +static CONTENT_HASH_FIELDS_TO_REMOVE: &[&str] = &["hashes", "signatures", "unsigned"]; + +/// The fields to remove from a JSON object when creating a reference hash of an event. +static REFERENCE_HASH_FIELDS_TO_REMOVE: &[&str] = &["age_ts", "signatures", "unsigned"]; + +/// Signs an arbitrary JSON object and adds the signature to an object under the key `signatures`. +/// +/// If `signatures` is already present, the new signature will be appended to the existing ones. +/// +/// # Parameters +/// +/// * entity_id: The identifier of the entity creating the signature. Generally this means a +/// homeserver, e.g. "example.com". +/// * key_pair: A cryptographic key pair used to sign the JSON. +/// * value: A JSON object to sign according and append a signature to. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * `value` is not a JSON object. +/// * `value` contains a field called `signatures` that is not a JSON object. +/// +/// # Examples +/// +/// A homeserver signs JSON with a key pair: +/// +/// ```rust +/// const PKCS8: &str = "MFMCAQEwBQYDK2VwBCIEINjozvdfbsGEt6DD+7Uf4PiJ/YvTNXV2mIPc/tA0T+6toSMDIQDdM+tpNzNWQM9NFpfgr4B9S7LHszOrVRp9NfKmeXS3aQ"; +/// +/// let document = base64::decode_config(&PKCS8, base64::STANDARD_NO_PAD).unwrap(); +/// +/// // Create an Ed25519 key pair. +/// let key_pair = ruma_signatures::Ed25519KeyPair::new( +/// &document, +/// "1".to_string(), // The "version" of the key. +/// ).unwrap(); +/// +/// // Deserialize some JSON. +/// let mut value = serde_json::from_str("{}").unwrap(); +/// +/// // Sign the JSON with the key pair. +/// assert!(ruma_signatures::sign_json("domain", &key_pair, &mut value).is_ok()); +/// ``` +/// +/// This will modify the JSON from an empty object to a structure like this: +/// +/// ```json +/// { +/// "signatures": { +/// "domain": { +/// "ed25519:1": "K8280/U9SSy9IVtjBuVeLr+HpOB4BQFWbg+UZaADMtTdGYI7Geitb76LTrr5QV/7Xg4ahLwYGYZzuHGZKM5ZAQ" +/// } +/// } +/// } +/// ``` +pub fn sign_json(entity_id: &str, key_pair: &K, value: &mut Value) -> Result<(), Error> +where + K: KeyPair, +{ + let mut signature_map; + let maybe_unsigned; + + // Pull `signatures` and `unsigned` out of the object, and limit the scope of the mutable + // borrow of `value` so we can call `to_string` with it below. + { + let map = match value { + Value::Object(ref mut map) => map, + _ => return Err(Error::new("JSON value must be a JSON object")), + }; + + signature_map = match map.remove("signatures") { + Some(signatures_value) => match signatures_value.as_object() { + Some(signatures) => from_value(Value::Object(signatures.clone()))?, + None => return Err(Error::new("field `signatures` must be a JSON object")), + }, + None => HashMap::with_capacity(1), + }; + + maybe_unsigned = map.remove("unsigned"); + } + + // Get the canonical JSON. + let json = to_string(&value)?; + + // Sign the canonical JSON. + let signature = key_pair.sign(json.as_bytes()); + + // Insert the new signature in the map we pulled out (or created) previously. + let signature_set = signature_map + .entry(entity_id.to_string()) + .or_insert_with(|| HashMap::with_capacity(1)); + + signature_set.insert(signature.id(), signature.base64()); + + // Safe to unwrap because we did this exact check at the beginning of the function. + let map = value.as_object_mut().unwrap(); + + // Put `signatures` and `unsigned` back in. + map.insert("signatures".to_string(), to_value(signature_map)?); + + if let Some(unsigned) = maybe_unsigned { + map.insert("unsigned".to_string(), to_value(unsigned)?); + } + + Ok(()) +} + +/// Converts a JSON object into the +/// [canonical](https://matrix.org/docs/spec/appendices#canonical-json) string form. +/// +/// # Parameters +/// +/// * value: The `serde_json::Value` (JSON value) to convert. +/// +/// # Errors +/// +/// Returns an error if the provided JSON value is not a JSON object. +/// +/// # Examples +/// +/// ```rust +/// let input = +/// r#"{ +/// "本": 2, +/// "日": 1 +/// }"#; +/// +/// let value = serde_json::from_str::(input).unwrap(); +/// +/// let canonical = ruma_signatures::canonical_json(&value).unwrap(); +/// +/// assert_eq!(canonical, r#"{"日":1,"本":2}"#); +/// ``` +pub fn canonical_json(value: &Value) -> Result { + canonical_json_with_fields_to_remove(value, CANONICAL_JSON_FIELDS_TO_REMOVE) +} + +/// Uses a set of public keys to verify a signed JSON object. +/// +/// # Parameters +/// +/// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. +/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. +/// * value: The `serde_json::Value` (JSON value) that was signed. +/// +/// # Errors +/// +/// Returns an error if verification fails. +/// +/// # Examples +/// +/// ```rust +/// use std::collections::HashMap; +/// +/// const PUBLIC_KEY: &str = "XGX0JRS2Af3be3knz2fBiRbApjm2Dh61gXDJA8kcJNI"; +/// +/// // Deserialize the signed JSON. +/// let value = serde_json::from_str( +/// r#"{ +/// "signatures": { +/// "domain": { +/// "ed25519:1": "K8280/U9SSy9IVtjBuVeLr+HpOB4BQFWbg+UZaADMtTdGYI7Geitb76LTrr5QV/7Xg4ahLwYGYZzuHGZKM5ZAQ" +/// } +/// } +/// }"# +/// ).unwrap(); +/// +/// // Create the `PublicKeyMap` that will inform `verify_json` which signatures to verify. +/// let mut public_key_set = HashMap::new(); +/// public_key_set.insert("ed25519:1".to_string(), PUBLIC_KEY.to_string()); +/// let mut public_key_map = HashMap::new(); +/// public_key_map.insert("domain".to_string(), public_key_set); +/// +/// // Verify at least one signature for each entity in `public_key_map`. +/// assert!(ruma_signatures::verify_json(&public_key_map, &value).is_ok()); +/// ``` +pub fn verify_json(public_key_map: &PublicKeyMap, value: &Value) -> Result<(), Error> { + let map = match value { + Value::Object(ref map) => map, + _ => return Err(Error::new("JSON value must be a JSON object")), + }; + + let signature_map: SignatureMap = match map.get("signatures") { + Some(signatures_value) => match signatures_value.as_object() { + Some(signatures) => from_value(Value::Object(signatures.clone()))?, + None => return Err(Error::new("field `signatures` must be a JSON object")), + }, + None => return Err(Error::new("JSON object must contain a `signatures` field.")), + }; + + for (entity_id, public_keys) in public_key_map { + let signature_set = match signature_map.get(entity_id) { + Some(set) => set, + None => { + return Err(Error::new(format!( + "no signatures found for entity `{}`", + entity_id + ))) + } + }; + + let mut maybe_signature = None; + let mut maybe_public_key = None; + + for (key_id, public_key) in public_keys { + // Since only ed25519 is supported right now, we don't actually need to check what the + // algorithm is. If it split successfully, it's ed25519. + if split_id(key_id).is_err() { + break; + } + + if let Some(signature) = signature_set.get(key_id) { + maybe_signature = Some(signature); + maybe_public_key = Some(public_key); + + break; + } + } + + let signature = match maybe_signature { + Some(signature) => signature, + None => { + return Err(Error::new( + "event is not signed with any of the given public keys", + )) + } + }; + + let public_key = match maybe_public_key { + Some(public_key) => public_key, + None => { + return Err(Error::new( + "event is not signed with any of the given public keys", + )) + } + }; + + let signature_bytes = decode_config(signature, STANDARD_NO_PAD)?; + + let public_key_bytes = decode_config(&public_key, STANDARD_NO_PAD)?; + + verify_json_with(&Ed25519Verifier, &public_key_bytes, &signature_bytes, value)?; + } + + Ok(()) +} + +/// Uses a public key to verify a signed JSON object. +/// +/// # Parameters +/// +/// * verifier: A `Verifier` appropriate for the digital signature algorithm that was used. +/// * public_key: The raw bytes of the public key used to sign the JSON. +/// * signature: The raw bytes of the signature. +/// * value: The `serde_json::Value` (JSON value) that was signed. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * The provided JSON value is not a JSON object. +/// * Verification fails. +fn verify_json_with( + verifier: &V, + public_key: &[u8], + signature: &[u8], + value: &Value, +) -> Result<(), Error> +where + V: Verifier, +{ + verifier.verify_json(public_key, signature, canonical_json(value)?.as_bytes()) +} + +/// Creates a *content hash* for the JSON representation of an event. +/// +/// Returns the hash as a Base64-encoded string, using the standard character set, without padding. +/// +/// The content hash of an event covers the complete event including the unredacted contents. It is +/// used during federation and is described in the Matrix server-server specification. +/// +/// # Parameters +/// +/// value: A JSON object to generate a content hash for. +/// +/// # Errors +/// +/// Returns an error if the provided JSON value is not a JSON object. +pub fn content_hash(value: &Value) -> Result { + let json = canonical_json_with_fields_to_remove(value, CONTENT_HASH_FIELDS_TO_REMOVE)?; + + let hash = digest(&SHA256, json.as_bytes()); + + Ok(encode_config(&hash, STANDARD_NO_PAD)) +} + +/// Creates a *reference hash* for the JSON representation of an event. +/// +/// Returns the hash as a Base64-encoded string, using the standard character set, without padding. +/// +/// The reference hash of an event covers the essential fields of an event, including content +/// hashes. It is used to generate event identifiers and is described in the Matrix server-server +/// specification. +/// +/// # Parameters +/// +/// value: A JSON object to generate a reference hash for. +/// +/// # Errors +/// +/// Returns an error if the provided JSON value is not a JSON object. +pub fn reference_hash(value: &Value) -> Result { + let redacted_value = redact(value)?; + + let json = + canonical_json_with_fields_to_remove(&redacted_value, REFERENCE_HASH_FIELDS_TO_REMOVE)?; + + let hash = digest(&SHA256, json.as_bytes()); + + Ok(encode_config(&hash, STANDARD_NO_PAD)) +} + +/// Hashes and signs the JSON representation of an event and adds the hash and signature to objects +/// under the keys `hashes` and `signatures`, respectively. +/// +/// If `hashes` and/or `signatures` are already present, the new data will be appended to the +/// existing data. +/// +/// # Parameters +/// +/// * entity_id: The identifier of the entity creating the signature. Generally this means a +/// homeserver, e.g. "example.com". +/// * key_pair: A cryptographic key pair used to sign the event. +/// * value: A JSON object to be hashed and signed according to the Matrix specification. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * `value` is not a JSON object. +/// * `value` contains a field called `content` that is not a JSON object. +/// * `value` contains a field called `hashes` that is not a JSON object. +/// * `value` contains a field called `signatures` that is not a JSON object. +/// * `value` is missing the `type` field or the field is not a JSON string. +/// +/// # Examples +/// +/// ```rust +/// const PKCS8: &str = "MFMCAQEwBQYDK2VwBCIEINjozvdfbsGEt6DD+7Uf4PiJ/YvTNXV2mIPc/tA0T+6toSMDIQDdM+tpNzNWQM9NFpfgr4B9S7LHszOrVRp9NfKmeXS3aQ"; +/// +/// let document = base64::decode_config(&PKCS8, base64::STANDARD_NO_PAD).unwrap(); +/// +/// // Create an Ed25519 key pair. +/// let key_pair = ruma_signatures::Ed25519KeyPair::new( +/// &document, +/// "1".to_string(), // The "version" of the key. +/// ).unwrap(); +/// +/// // Deserialize an event from JSON. +/// let mut value = serde_json::from_str( +/// r#"{ +/// "room_id": "!x:domain", +/// "sender": "@a:domain", +/// "origin": "domain", +/// "origin_server_ts": 1000000, +/// "signatures": {}, +/// "hashes": {}, +/// "type": "X", +/// "content": {}, +/// "prev_events": [], +/// "auth_events": [], +/// "depth": 3, +/// "unsigned": { +/// "age_ts": 1000000 +/// } +/// }"# +/// ).unwrap(); +/// +/// // Hash and sign the JSON with the key pair. +/// assert!(ruma_signatures::hash_and_sign_event("domain", &key_pair, &mut value).is_ok()); +/// ``` +/// +/// This will modify the JSON from the structure shown to a structure like this: +/// +/// ```json +/// { +/// "auth_events": [], +/// "content": {}, +/// "depth": 3, +/// "hashes": { +/// "sha256": "5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos" +/// }, +/// "origin": "domain", +/// "origin_server_ts": 1000000, +/// "prev_events": [], +/// "room_id": "!x:domain", +/// "sender": "@a:domain", +/// "signatures": { +/// "domain": { +/// "ed25519:1": "KxwGjPSDEtvnFgU00fwFz+l6d2pJM6XBIaMEn81SXPTRl16AqLAYqfIReFGZlHi5KLjAWbOoMszkwsQma+lYAg" +/// } +/// }, +/// "type": "X", +/// "unsigned": { +/// "age_ts": 1000000 +/// } +/// } +/// ``` +/// +/// Notice the addition of `hashes` and `signatures`. +pub fn hash_and_sign_event(entity_id: &str, key_pair: &K, value: &mut Value) -> Result<(), Error> +where + K: KeyPair, +{ + let hash = content_hash(value)?; + + // Limit the scope of the mutable borrow so `value` can be passed immutably to `redact` below. + { + let map = match value { + Value::Object(ref mut map) => map, + _ => return Err(Error::new("JSON value must be a JSON object")), + }; + + let hashes_value = map + .entry("hashes") + .or_insert_with(|| Value::Object(Map::with_capacity(1))); + + match hashes_value.as_object_mut() { + Some(hashes) => hashes.insert("sha256".to_string(), Value::String(hash)), + None => return Err(Error::new("field `hashes` must be a JSON object")), + }; + } + + let mut redacted = redact(value)?; + + sign_json(entity_id, key_pair, &mut redacted)?; + + // Safe to unwrap because we did this exact check at the beginning of the function. + let map = value.as_object_mut().unwrap(); + + map.insert("signatures".to_string(), redacted["signatures"].take()); + + Ok(()) +} + +/// Uses a set of public keys to verify a signed JSON representation of an event. +/// +/// Some room versions may require signatures from multiple homeservers, so this function takes a +/// map from servers to sets of public keys. For each homeserver present in the map, this function +/// will require a valid signature. All known public keys for a homeserver should be provided. The +/// first one found on the given event will be used. +/// +/// If the `Ok` variant is returned by this function, it will contain a `Verified` value which +/// distinguishes an event with valid signatures and a matching content hash with an event with +/// only valid signatures. See the documetation for `Verified` for details. +/// +/// # Parameters +/// +/// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. +/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. +/// * value: The `serde_json::Value` (JSON value) of the event that was signed. +/// +/// # Examples +/// +/// ```rust +/// use std::collections::HashMap; +/// +/// const PUBLIC_KEY: &str = "XGX0JRS2Af3be3knz2fBiRbApjm2Dh61gXDJA8kcJNI"; +/// +/// // Deserialize an event from JSON. +/// let value = serde_json::from_str( +/// r#"{ +/// "auth_events": [], +/// "content": {}, +/// "depth": 3, +/// "hashes": { +/// "sha256": "5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos" +/// }, +/// "origin": "domain", +/// "origin_server_ts": 1000000, +/// "prev_events": [], +/// "room_id": "!x:domain", +/// "sender": "@a:domain", +/// "signatures": { +/// "domain": { +/// "ed25519:1": "KxwGjPSDEtvnFgU00fwFz+l6d2pJM6XBIaMEn81SXPTRl16AqLAYqfIReFGZlHi5KLjAWbOoMszkwsQma+lYAg" +/// } +/// }, +/// "type": "X", +/// "unsigned": { +/// "age_ts": 1000000 +/// } +/// }"# +/// ).unwrap(); +/// +/// // Create the `PublicKeyMap` that will inform `verify_json` which signatures to verify. +/// let mut public_key_set = HashMap::new(); +/// public_key_set.insert("ed25519:1".to_string(), PUBLIC_KEY.to_string()); +/// let mut public_key_map = HashMap::new(); +/// public_key_map.insert("domain".to_string(), public_key_set); +/// +/// // Verify at least one signature for each entity in `public_key_map`. +/// assert!(ruma_signatures::verify_event(&public_key_map, &value).is_ok()); +/// ``` +pub fn verify_event(public_key_map: &PublicKeyMap, value: &Value) -> Result { + let redacted = redact(value)?; + + let map = match redacted { + Value::Object(ref map) => map, + _ => return Err(Error::new("JSON value must be a JSON object")), + }; + + let hash = match map.get("hashes") { + Some(hashes_value) => match hashes_value.as_object() { + Some(hashes) => match hashes.get("sha256") { + Some(hash_value) => match hash_value.as_str() { + Some(hash) => hash, + None => return Err(Error::new("sha256 hash must be a JSON string")), + }, + None => return Err(Error::new("field `hashes` must be a JSON object")), + }, + None => return Err(Error::new("event missing sha256 hash")), + }, + None => return Err(Error::new("field `hashes` must be present")), + }; + + let signature_map: SignatureMap = match map.get("signatures") { + Some(signatures_value) => match signatures_value.as_object() { + Some(signatures) => from_value(Value::Object(signatures.clone()))?, + None => return Err(Error::new("field `signatures` must be a JSON object")), + }, + None => return Err(Error::new("JSON object must contain a `signatures` field.")), + }; + + for (entity_id, public_keys) in public_key_map { + let signature_set = match signature_map.get(entity_id) { + Some(set) => set, + None => { + return Err(Error::new(format!( + "no signatures found for entity `{}`", + entity_id + ))) + } + }; + + let mut maybe_signature = None; + let mut maybe_public_key = None; + + for (key_id, public_key) in public_keys { + // Since only ed25519 is supported right now, we don't actually need to check what the + // algorithm is. If it split successfully, it's ed25519. + if split_id(key_id).is_err() { + break; + } + + if let Some(signature) = signature_set.get(key_id) { + maybe_signature = Some(signature); + maybe_public_key = Some(public_key); + + break; + } + } + + let signature = match maybe_signature { + Some(signature) => signature, + None => { + return Err(Error::new( + "event is not signed with any of the given public keys", + )) + } + }; + + let public_key = match maybe_public_key { + Some(public_key) => public_key, + None => { + return Err(Error::new( + "event is not signed with any of the given public keys", + )) + } + }; + + let canonical_json = from_str(&canonical_json(&redacted)?)?; + + let signature_bytes = decode_config(signature, STANDARD_NO_PAD)?; + + let public_key_bytes = decode_config(&public_key, STANDARD_NO_PAD)?; + + verify_json_with( + &Ed25519Verifier, + &public_key_bytes, + &signature_bytes, + &canonical_json, + )?; + } + + let calculated_hash = content_hash(value)?; + + if hash == calculated_hash { + Ok(Verified::All) + } else { + Ok(Verified::Signatures) + } +} + +/// Internal implementation detail of the canonical JSON algorithm. Allows customization of the +/// fields that will be removed before serializing. +fn canonical_json_with_fields_to_remove(value: &Value, fields: &[&str]) -> Result { + if !value.is_object() { + return Err(Error::new("JSON value must be a JSON object")); + } + + let mut owned_value = value.clone(); + + { + let object = owned_value + .as_object_mut() + .expect("safe since we checked above"); + + for field in fields { + object.remove(*field); + } + } + + to_string(&owned_value).map_err(Error::from) +} + +/// Redacts the JSON representation of an event using the rules specified in the Matrix +/// client-server specification. +/// +/// This is part of the process of signing an event. +/// +/// Redaction is also suggested when a verifying an event with `verify_event` returns +/// `Verified::Signatures`. See the documentation for `Verified` for details. +/// +/// Returns a new `serde_json::Value` with all applicable fields redacted. +/// +/// # Parameters +/// +/// * value: A JSON object to redact. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * `value` is not a JSON object. +/// * `value` contains a field called `content` that is not a JSON object. +/// * `value` contains a field called `hashes` that is not a JSON object. +/// * `value` contains a field called `signatures` that is not a JSON object. +/// * `value` is missing the `type` field or the field is not a JSON string. +pub fn redact(value: &Value) -> Result { + if !value.is_object() { + return Err(Error::new("JSON value must be a JSON object")); + } + + let mut owned_value = value.clone(); + + let event = owned_value + .as_object_mut() + .expect("safe since we checked above"); + + let event_type_value = match event.get("type") { + Some(event_type_value) => event_type_value, + None => return Err(Error::new("field `type` in JSON value must be present")), + }; + + let event_type = match event_type_value.as_str() { + Some(event_type) => event_type.to_string(), + None => { + return Err(Error::new( + "field `type` in JSON value must be a JSON string", + )) + } + }; + + if let Some(content_value) = event.get_mut("content") { + let map = match content_value { + Value::Object(ref mut map) => map, + _ => { + return Err(Error::new( + "field `content` in JSON value must be a JSON object", + )) + } + }; + + for key in map.clone().keys() { + match event_type.as_ref() { + "m.room.member" if key != "membership" => map.remove(key), + "m.room.create" if key != "creator" => map.remove(key), + "m.room.join_rules" if key != "join_rules" => map.remove(key), + "m.room.power_levels" if !ALLOWED_POWER_LEVELS_KEYS.contains(&key.as_ref()) => { + map.remove(key) + } + "m.room.aliases" if key != "aliases" => map.remove(key), + "m.room.history_visibility" if key != "history_visibility" => map.remove(key), + _ => map.remove(key), + }; + } + } + + for key in event.clone().keys() { + if !ALLOWED_KEYS.contains(&key.as_ref()) { + event.remove(key); + } + } + + Ok(owned_value) +} diff --git a/ruma-signatures/src/keys.rs b/ruma-signatures/src/keys.rs new file mode 100644 index 00000000..a8921230 --- /dev/null +++ b/ruma-signatures/src/keys.rs @@ -0,0 +1,117 @@ +//! Public and private key pairs. + +use std::{ + collections::HashMap, + fmt::{Debug, Formatter, Result as FmtResult}, +}; + +use ring::signature::{Ed25519KeyPair as RingEd25519KeyPair, KeyPair as _}; + +use crate::{signatures::Signature, Algorithm, Error}; + +/// A cryptographic key pair for digitally signing data. +pub trait KeyPair: Sized { + /// Signs a JSON object. + /// + /// # Parameters + /// + /// * message: An arbitrary series of bytes to sign. + fn sign(&self, message: &[u8]) -> Signature; +} + +/// An Ed25519 key pair. +pub struct Ed25519KeyPair { + /// Ring's Keypair type + keypair: RingEd25519KeyPair, + + /// The version of the key pair. + version: String, +} + +impl Ed25519KeyPair { + /// Initializes a new key pair. + /// + /// # Parameters + /// + /// * document: PKCS8-formatted bytes containing the private & public keys. + /// * version: The "version" of the key used for this signature. + /// Versions are used as an identifier to distinguish signatures generated from different keys + /// but using the same algorithm on the same homeserver. + /// + /// # Errors + /// + /// Returns an error if the public and private keys provided are invalid for the implementing + /// algorithm. + pub fn new(document: &[u8], version: String) -> Result { + let keypair = RingEd25519KeyPair::from_pkcs8(document) + .map_err(|error| Error::new(error.to_string()))?; + + Ok(Self { keypair, version }) + } + + /// Generates a new key pair. + /// + /// # Returns + /// + /// Returns a Vec representing a pkcs8-encoded private/public keypair + /// + /// # Errors + /// + /// Returns an error if the generation failed. + pub fn generate() -> Result, Error> { + let document = RingEd25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .map_err(|e| Error::new(e.to_string()))?; + + Ok(document.as_ref().to_vec()) + } + + /// Returns the version string for this keypair. + pub fn version(&self) -> &str { + &self.version + } + + /// Returns the public key. + pub fn public_key(&self) -> &[u8] { + self.keypair.public_key().as_ref() + } +} + +impl KeyPair for Ed25519KeyPair { + fn sign(&self, message: &[u8]) -> Signature { + Signature { + algorithm: Algorithm::Ed25519, + signature: self.keypair.sign(message).as_ref().to_vec(), + version: self.version.clone(), + } + } +} + +impl Debug for Ed25519KeyPair { + fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult { + formatter + .debug_struct("Ed25519KeyPair") + .field("public_key", &self.keypair.public_key()) + .field("version", &self.version) + .finish() + } +} + +/// A map from entity names to sets of public keys for that entity. +/// +/// "Entity" is generally a homeserver, e.g. "example.com". +pub type PublicKeyMap = HashMap; + +/// A set of public keys for a single homeserver. +/// +/// This is represented as a map from key ID to Base64-encoded signature. +pub type PublicKeySet = HashMap; + +#[cfg(test)] +mod tests { + use super::Ed25519KeyPair; + + #[test] + fn generate_key() { + Ed25519KeyPair::generate().unwrap(); + } +} diff --git a/ruma-signatures/src/lib.rs b/ruma-signatures/src/lib.rs new file mode 100644 index 00000000..907e8787 --- /dev/null +++ b/ruma-signatures/src/lib.rs @@ -0,0 +1,507 @@ +//! Crate `ruma_signatures` implements digital signatures according to the +//! [Matrix](https://matrix.org/) specification. +//! +//! Digital signatures are used by Matrix homeservers to verify the authenticity of events in the +//! Matrix system, as well as requests between homeservers for federation. Each homeserver has one +//! or more signing key pairs (sometimes referred to as "verify keys") which it uses to sign all +//! events and federation requests. Matrix clients and other Matrix homeservers can ask the +//! homeserver for its public keys and use those keys to verify the signed data. +//! +//! Each signing key pair has an identifier, which consists of the name of the digital signature +//! algorithm it uses and a "version" string, separated by a colon. The version is an arbitrary +//! identifier used to distinguish key pairs using the same algorithm from the same homeserver. +//! +//! Arbitrary JSON objects can be signed as well as JSON representations of Matrix events. In both +//! cases, the signatures are stored within the JSON object itself under a `signatures` key. Events +//! are also required to contain hashes of their content, which are similarly stored within the +//! hashed JSON object under a `hashes` key. +//! +//! In JSON representations, both signatures and hashes appear as Base64-encoded strings, using the +//! standard character set, without padding. +//! +//! # Signing and hashing +//! +//! To sign an arbitrary JSON object, use the `sign_json` function. See the documentation of this +//! function for more details and a full example of use. +//! +//! Signing an event uses a more complicated process than signing arbitrary JSON, because events can +//! be redacted, and signatures need to remain valid even if data is removed from an event later. +//! Homeservers are required to generate hashes of event contents as well as signing events before +//! exchanging them with other homeservers. Although the algorithm for hashing and signing an event +//! is more complicated than for signing arbitrary JSON, the interface to a user of ruma-signatures +//! is the same. To hash and sign an event, use the `hash_and_sign_event` function. See the +//! documentation of this function for more details and a full example of use. +//! +//! # Verifying signatures and hashes +//! +//! When a homeserver receives data from another homeserver via the federation, it's necessary to +//! verify the authenticity and integrity of the data by verifying their signatures. +//! +//! To verify a signature on arbitrary JSON, use the `verify_json` function. To verify the +//! signatures and hashes on an event, use the `verify_event` function. See the documentation for +//! these respective functions for more details and full examples of use. + +#![warn(rust_2018_idioms)] +#![deny( + missing_copy_implementations, + missing_debug_implementations, + missing_docs +)] + +use std::{ + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, +}; + +pub use functions::{ + canonical_json, content_hash, hash_and_sign_event, redact, reference_hash, sign_json, + verify_event, verify_json, +}; +pub use keys::{Ed25519KeyPair, KeyPair, PublicKeyMap, PublicKeySet}; +pub use signatures::Signature; + +mod functions; +mod keys; +mod signatures; +mod verification; + +/// An error produced when ruma-signatures operations fail. +#[derive(Clone, Debug, PartialEq)] +pub struct Error { + /// A human-readable description of the error. + message: String, +} + +impl Error { + /// Creates a new error. + /// + /// # Parameters + /// + /// * message: The error message. + pub(crate) fn new(message: T) -> Self + where + T: Into, + { + Self { + message: message.into(), + } + } +} + +impl StdError for Error { + fn description(&self) -> &str { + &self.message + } +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "{}", self.message) + } +} + +impl From for Error { + fn from(error: base64::DecodeError) -> Self { + Self::new(error.to_string()) + } +} + +impl From for Error { + fn from(error: serde_json::Error) -> Self { + Self::new(error.to_string()) + } +} + +/// The algorithm used for signing data. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Algorithm { + /// The Ed25519 digital signature algorithm. + Ed25519, +} + +impl Display for Algorithm { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let name = match *self { + Self::Ed25519 => "ed25519", + }; + + write!(f, "{}", name) + } +} + +/// An error when trying to extract the algorithm and version from a key identifier. +#[derive(Clone, Debug, PartialEq)] +enum SplitError<'a> { + /// The signature's ID does not have exactly two components separated by a colon. + InvalidLength(usize), + /// The signature's ID contains invalid characters in its version. + InvalidVersion(&'a str), + /// The signature uses an unknown algorithm. + UnknownAlgorithm(&'a str), +} + +/// Extract the algorithm and version from a key identifier. +fn split_id(id: &str) -> Result<(Algorithm, String), SplitError<'_>> { + /// The length of a valid signature ID. + const SIGNATURE_ID_LENGTH: usize = 2; + + let signature_id: Vec<&str> = id.split(':').collect(); + + let signature_id_length = signature_id.len(); + + if signature_id_length != SIGNATURE_ID_LENGTH { + return Err(SplitError::InvalidLength(signature_id_length)); + } + + let version = signature_id[1]; + + let invalid_character_index = version.find(|ch| { + !((ch >= 'a' && ch <= 'z') + || (ch >= 'A' && ch <= 'Z') + || (ch >= '0' && ch <= '9') + || ch == '_') + }); + + if invalid_character_index.is_some() { + return Err(SplitError::InvalidVersion(version)); + } + + let algorithm_input = signature_id[0]; + + let algorithm = match algorithm_input { + "ed25519" => Algorithm::Ed25519, + algorithm => return Err(SplitError::UnknownAlgorithm(algorithm)), + }; + + Ok((algorithm, signature_id[1].to_string())) +} + +#[cfg(test)] +mod test { + use std::collections::HashMap; + + use base64::{decode_config, STANDARD_NO_PAD}; + use ring::signature::{Ed25519KeyPair as RingEd25519KeyPair, KeyPair as _}; + use serde_json::{from_str, json, to_string, to_value, Value}; + + use super::{ + canonical_json, hash_and_sign_event, sign_json, verify_event, verify_json, Ed25519KeyPair, + }; + + const PKCS8: &str = "MFMCAQEwBQYDK2VwBCIEINjozvdfbsGEt6DD+7Uf4PiJ/YvTNXV2mIPc/tA0T+6toSMDIQDdM+tpNzNWQM9NFpfgr4B9S7LHszOrVRp9NfKmeXS3aQ"; + + /// Convenience method for getting the public key as a string + fn public_key_string() -> String { + base64::encode_config( + &RingEd25519KeyPair::from_pkcs8( + &base64::decode_config(PKCS8, STANDARD_NO_PAD).unwrap(), + ) + .unwrap() + .public_key(), + STANDARD_NO_PAD, + ) + } + + /// Convenience for converting a string of JSON into its canonical form. + fn test_canonical_json(input: &str) -> String { + let value = from_str::(input).unwrap(); + + canonical_json(&value).unwrap() + } + + #[test] + fn canonical_json_examples() { + assert_eq!(&test_canonical_json("{}"), "{}"); + + assert_eq!( + &test_canonical_json( + r#"{ + "one": 1, + "two": "Two" + }"# + ), + r#"{"one":1,"two":"Two"}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "b": "2", + "a": "1" + }"# + ), + r#"{"a":"1","b":"2"}"# + ); + + assert_eq!( + &test_canonical_json(r#"{"b":"2","a":"1"}"#), + r#"{"a":"1","b":"2"}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "auth": { + "success": true, + "mxid": "@john.doe:example.com", + "profile": { + "display_name": "John Doe", + "three_pids": [ + { + "medium": "email", + "address": "john.doe@example.org" + }, + { + "medium": "msisdn", + "address": "123456789" + } + ] + } + } + }"# + ), + r#"{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe","three_pids":[{"address":"john.doe@example.org","medium":"email"},{"address":"123456789","medium":"msisdn"}]},"success":true}}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "a": "日本語" + }"# + ), + r#"{"a":"日本語"}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "本": 2, + "日": 1 + }"# + ), + r#"{"日":1,"本":2}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "a": "\u65E5" + }"# + ), + r#"{"a":"日"}"# + ); + + assert_eq!( + &test_canonical_json( + r#"{ + "a": null + }"# + ), + r#"{"a":null}"# + ); + } + + #[test] + fn sign_empty_json() { + let key_pair = Ed25519KeyPair::new( + decode_config(&PKCS8, STANDARD_NO_PAD).unwrap().as_slice(), + "1".to_string(), + ) + .unwrap(); + + let mut value = from_str("{}").unwrap(); + + sign_json("domain", &key_pair, &mut value).unwrap(); + + assert_eq!( + to_string(&value).unwrap(), + r#"{"signatures":{"domain":{"ed25519:1":"lXjsnvhVlz8t3etR+6AEJ0IT70WujeHC1CFjDDsVx0xSig1Bx7lvoi1x3j/2/GPNjQM4a2gD34UqsXFluaQEBA"}}}"# + ); + } + + #[test] + fn verify_empty_json() { + let value = from_str(r#"{"signatures":{"domain":{"ed25519:1":"lXjsnvhVlz8t3etR+6AEJ0IT70WujeHC1CFjDDsVx0xSig1Bx7lvoi1x3j/2/GPNjQM4a2gD34UqsXFluaQEBA"}}}"#).unwrap(); + + let mut signature_set = HashMap::new(); + signature_set.insert("ed25519:1".to_string(), public_key_string()); + + let mut public_key_map = HashMap::new(); + public_key_map.insert("domain".to_string(), signature_set); + + assert!(verify_json(&public_key_map, &value).is_ok()); + } + + #[test] + fn sign_minimal_json() { + let key_pair = Ed25519KeyPair::new( + decode_config(&PKCS8, STANDARD_NO_PAD).unwrap().as_slice(), + "1".to_string(), + ) + .unwrap(); + + let alpha = json!({ + "one": 1, + "two": "Two", + }); + + let reverse_alpha = json!({ + "two": "Two", + "one": 1, + }); + + let mut alpha_value = to_value(alpha).expect("alpha should serialize"); + sign_json("domain", &key_pair, &mut alpha_value).unwrap(); + + assert_eq!( + to_string(&alpha_value).unwrap(), + r#"{"one":1,"signatures":{"domain":{"ed25519:1":"t6Ehmh6XTDz7qNWI0QI5tNPSliWLPQP/+Fzz3LpdCS7q1k2G2/5b5Embs2j4uG3ZeivejrzqSVoBcdocRpa+AQ"}},"two":"Two"}"# + ); + + let mut reverse_alpha_value = + to_value(reverse_alpha).expect("reverse_alpha should serialize"); + sign_json("domain", &key_pair, &mut reverse_alpha_value).unwrap(); + + assert_eq!( + to_string(&reverse_alpha_value).unwrap(), + r#"{"one":1,"signatures":{"domain":{"ed25519:1":"t6Ehmh6XTDz7qNWI0QI5tNPSliWLPQP/+Fzz3LpdCS7q1k2G2/5b5Embs2j4uG3ZeivejrzqSVoBcdocRpa+AQ"}},"two":"Two"}"# + ); + } + + #[test] + fn verify_minimal_json() { + let value = from_str( + r#"{"one":1,"signatures":{"domain":{"ed25519:1":"t6Ehmh6XTDz7qNWI0QI5tNPSliWLPQP/+Fzz3LpdCS7q1k2G2/5b5Embs2j4uG3ZeivejrzqSVoBcdocRpa+AQ"}},"two":"Two"}"# + ).unwrap(); + + let mut signature_set = HashMap::new(); + signature_set.insert("ed25519:1".to_string(), public_key_string()); + + let mut public_key_map = HashMap::new(); + public_key_map.insert("domain".to_string(), signature_set); + + assert!(verify_json(&public_key_map, &value).is_ok()); + + let reverse_value = from_str( + r#"{"two":"Two","signatures":{"domain":{"ed25519:1":"t6Ehmh6XTDz7qNWI0QI5tNPSliWLPQP/+Fzz3LpdCS7q1k2G2/5b5Embs2j4uG3ZeivejrzqSVoBcdocRpa+AQ"}},"one":1}"# + ).unwrap(); + + assert!(verify_json(&public_key_map, &reverse_value).is_ok()); + } + + #[test] + fn fail_verify_json() { + let value = from_str(r#"{"not":"empty","signatures":{"domain":"lXjsnvhVlz8t3etR+6AEJ0IT70WujeHC1CFjDDsVx0xSig1Bx7lvoi1x3j/2/GPNjQM4a2gD34UqsXFluaQEBA"}}"#).unwrap(); + + let mut signature_set = HashMap::new(); + signature_set.insert("ed25519:1".to_string(), public_key_string()); + + let mut public_key_map = HashMap::new(); + public_key_map.insert("domain".to_string(), signature_set); + + assert!(verify_json(&public_key_map, &value).is_err()); + } + + #[test] + fn sign_minimal_event() { + let key_pair = Ed25519KeyPair::new( + decode_config(&PKCS8, STANDARD_NO_PAD).unwrap().as_slice(), + "1".to_string(), + ) + .unwrap(); + + let json = r#"{ + "room_id": "!x:domain", + "sender": "@a:domain", + "origin": "domain", + "origin_server_ts": 1000000, + "signatures": {}, + "hashes": {}, + "type": "X", + "content": {}, + "prev_events": [], + "auth_events": [], + "depth": 3, + "unsigned": { + "age_ts": 1000000 + } + }"#; + + let mut value = from_str::(json).unwrap(); + hash_and_sign_event("domain", &key_pair, &mut value).unwrap(); + + assert_eq!( + to_string(&value).unwrap(), + r#"{"auth_events":[],"content":{},"depth":3,"hashes":{"sha256":"5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos"},"origin":"domain","origin_server_ts":1000000,"prev_events":[],"room_id":"!x:domain","sender":"@a:domain","signatures":{"domain":{"ed25519:1":"PxOFMn6ORll8PFSQp0IRF6037MEZt3Mfzu/ROiT/gb/ccs1G+f6Ddoswez4KntLPBI3GKCGIkhctiK37JOy2Aw"}},"type":"X","unsigned":{"age_ts":1000000}}"# + ); + } + + #[test] + fn sign_redacted_event() { + let key_pair = Ed25519KeyPair::new( + decode_config(&PKCS8, STANDARD_NO_PAD).unwrap().as_slice(), + "1".to_string(), + ) + .unwrap(); + + let json = r#"{ + "content": { + "body": "Here is the message content" + }, + "event_id": "$0:domain", + "origin": "domain", + "origin_server_ts": 1000000, + "type": "m.room.message", + "room_id": "!r:domain", + "sender": "@u:domain", + "signatures": {}, + "unsigned": { + "age_ts": 1000000 + } + }"#; + + let mut value = from_str::(json).unwrap(); + hash_and_sign_event("domain", &key_pair, &mut value).unwrap(); + + assert_eq!( + to_string(&value).unwrap(), + r#"{"content":{"body":"Here is the message content"},"event_id":"$0:domain","hashes":{"sha256":"onLKD1bGljeBWQhWZ1kaP9SorVmRQNdN5aM2JYU2n/g"},"origin":"domain","origin_server_ts":1000000,"room_id":"!r:domain","sender":"@u:domain","signatures":{"domain":{"ed25519:1":"D2V+qWBJssVuK/pEUJtwaYMdww2q1fP4PRCo226ChlLz8u8AWmQdLKes19NMjs/X0Hv0HIjU0c1TDKFMtGuoCA"}},"type":"m.room.message","unsigned":{"age_ts":1000000}}"# + ); + } + + #[test] + fn verify_minimal_event() { + let mut signature_set = HashMap::new(); + signature_set.insert("ed25519:1".to_string(), public_key_string()); + + let mut public_key_map = HashMap::new(); + public_key_map.insert("domain".to_string(), signature_set); + + let value = from_str( + r#"{ + "auth_events": [], + "content": {}, + "depth": 3, + "hashes": { + "sha256": "5jM4wQpv6lnBo7CLIghJuHdW+s2CMBJPUOGOC89ncos" + }, + "origin": "domain", + "origin_server_ts": 1000000, + "prev_events": [], + "room_id": "!x:domain", + "sender": "@a:domain", + "signatures": { + "domain": { + "ed25519:1": "PxOFMn6ORll8PFSQp0IRF6037MEZt3Mfzu/ROiT/gb/ccs1G+f6Ddoswez4KntLPBI3GKCGIkhctiK37JOy2Aw" + } + }, + "type": "X", + "unsigned": { + "age_ts": 1000000 + } + }"# + ).unwrap(); + + assert!(verify_event(&public_key_map, &value).is_ok()); + } +} diff --git a/ruma-signatures/src/signatures.rs b/ruma-signatures/src/signatures.rs new file mode 100644 index 00000000..f1a03033 --- /dev/null +++ b/ruma-signatures/src/signatures.rs @@ -0,0 +1,127 @@ +//! Digital signatures and collections of signatures. + +use std::collections::HashMap; + +use base64::{encode_config, STANDARD_NO_PAD}; + +use crate::{split_id, Algorithm, Error, SplitError}; + +/// A digital signature. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct Signature { + /// The cryptographic algorithm that generated this signature. + pub algorithm: Algorithm, + + /// The signature data. + pub signature: Vec, + + /// The "version" of the key identifier for the public key used to generate this signature. + pub version: String, +} + +impl Signature { + /// Creates a signature from raw bytes. + /// + /// While a signature can be created directly using struct literal syntax, this constructor can + /// be used to automatically determine the algorithm and version from a key identifier in the + /// form *algorithm:version*, e.g. "ed25519:1". + /// + /// This constructor will ensure that the version does not contain characters that violate the + /// guidelines in the specification. Because it may be necessary to represent signatures with + /// versions that don't adhere to these guidelines, it's possible to simply use the struct + /// literal syntax to construct a `Signature` with an arbitrary key. + /// + /// # Parameters + /// + /// * id: A key identifier, e.g. "ed25519:1". + /// * bytes: The digital signature, as a series of bytes. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * The key ID specifies an unknown algorithm. + /// * The key ID is malformed. + /// * The key ID contains a version with invalid characters. + pub fn new(id: &str, bytes: &[u8]) -> Result { + let (algorithm, version) = split_id(id).map_err(|split_error| match split_error { + SplitError::InvalidLength(length) => Error::new(format!("malformed signature ID: expected exactly 2 segment separated by a colon, found {}", length)), + SplitError::InvalidVersion(version) => Error::new(format!("malformed signature ID: expected version to contain only characters in the character set `[a-zA-Z0-9_]`, found `{}`", version)), + SplitError::UnknownAlgorithm(algorithm) => { + Error::new(format!("unknown algorithm: {}", algorithm)) + } + })?; + + Ok(Self { + algorithm, + signature: bytes.to_vec(), + version, + }) + } + + /// The algorithm used to generate the signature. + pub fn algorithm(&self) -> Algorithm { + self.algorithm + } + + /// The raw bytes of the signature. + pub fn as_bytes(&self) -> &[u8] { + self.signature.as_slice() + } + + /// A Base64 encoding of the signature. + /// + /// Uses the standard character set with no padding. + pub fn base64(&self) -> String { + encode_config(self.signature.as_slice(), STANDARD_NO_PAD) + } + + /// The key identifier, a string containing the signature algorithm and the key "version" + /// separated by a colon, e.g. "ed25519:1". + pub fn id(&self) -> String { + format!("{}:{}", self.algorithm, self.version) + } + + /// The "version" of the key used for this signature. + /// + /// Versions are used as an identifier to distinguish signatures generated from different keys + /// but using the same algorithm on the same homeserver. + pub fn version(&self) -> &str { + &self.version + } +} + +/// A map from entity names to sets of digital signatures created by that entity. +/// +/// "Entity" is generally a homeserver, e.g. "example.com". +pub type SignatureMap = HashMap; + +/// A set of digital signatures created by a single homeserver. +/// +/// This is represented as a map from signing key ID to Base64-encoded signature. +pub type SignatureSet = HashMap; + +#[cfg(test)] +mod tests { + use super::Signature; + + #[test] + fn valid_key_id() { + assert!(Signature::new("ed25519:abcdef", &[]).is_ok()); + } + + #[test] + fn invalid_valid_key_id_length() { + assert!(Signature::new("ed25519:abcdef:123456", &[]).is_err()); + } + + #[test] + fn invalid_key_id_version() { + assert!(Signature::new("ed25519:abc!def", &[]).is_err()); + } + + #[test] + fn invalid_key_id_algorithm() { + assert!(Signature::new("foobar:abcdef", &[]).is_err()); + } +} diff --git a/ruma-signatures/src/verification.rs b/ruma-signatures/src/verification.rs new file mode 100644 index 00000000..f8df82be --- /dev/null +++ b/ruma-signatures/src/verification.rs @@ -0,0 +1,62 @@ +//! Verification of digital signatures. + +use ring::signature::{VerificationAlgorithm, ED25519}; +use untrusted::Input; + +use crate::Error; + +/// A digital signature verifier. +pub trait Verifier { + /// Use a public key to verify a signature against the JSON object that was signed. + /// + /// # Parameters + /// + /// * public_key: The raw bytes of the public key of the key pair used to sign the message. + /// * signature: The raw bytes of the signature to verify. + /// * message: The raw bytes of the message that was signed. + /// + /// # Errors + /// + /// Returns an error if verification fails. + fn verify_json(&self, public_key: &[u8], signature: &[u8], message: &[u8]) + -> Result<(), Error>; +} + +/// A verifier for Ed25519 digital signatures. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Ed25519Verifier; + +impl Verifier for Ed25519Verifier { + fn verify_json( + &self, + public_key: &[u8], + signature: &[u8], + message: &[u8], + ) -> Result<(), Error> { + ED25519 + .verify( + Input::from(public_key), + Input::from(message), + Input::from(signature), + ) + .map_err(|_| Error::new("signature verification failed")) + } +} + +/// A value returned when an event is successfully verified. +/// +/// Event verification involves verifying both signatures and a content hash. It is possible for +/// the signatures on an event to be valid, but for the hash to be different than the one +/// calculated during verification. This is not necessarily an error condition, as it may indicate +/// that the event has been redacted. In this case, receiving homeservers should store a redacted +/// version of the event. +#[derive(Debug, Clone, Copy, Hash, PartialEq)] +pub enum Verified { + /// All signatures are valid and the content hashes match. + All, + + /// All signatures are valid but the content hashes don't match. + /// + /// This may indicate a redacted event. + Signatures, +}