490 lines
17 KiB
Rust
490 lines
17 KiB
Rust
#![doc(html_favicon_url = "https://www.ruma.io/favicon.ico")]
|
|
#![doc(html_logo_url = "https://www.ruma.io/images/logo.png")]
|
|
//! 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_debug_implementations, missing_docs)]
|
|
|
|
use std::{
|
|
error::Error as StdError,
|
|
fmt::{Display, Formatter, Result as FmtResult},
|
|
};
|
|
|
|
use ruma_serde::{AsRefStr, DisplayAsRefStr};
|
|
|
|
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 ruma_serde::{CanonicalJsonError, CanonicalJsonObject, CanonicalJsonValue};
|
|
pub use signatures::Signature;
|
|
pub use verification::Verified;
|
|
|
|
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<T>(message: T) -> Self
|
|
where
|
|
T: Into<String>,
|
|
{
|
|
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<base64::DecodeError> for Error {
|
|
fn from(error: base64::DecodeError) -> Self {
|
|
Self::new(error.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for Error {
|
|
fn from(error: serde_json::Error) -> Self {
|
|
Self::new(error.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<ruma_serde::CanonicalJsonError> for Error {
|
|
fn from(error: ruma_serde::CanonicalJsonError) -> Self {
|
|
Self::new(error.to_string())
|
|
}
|
|
}
|
|
|
|
/// The algorithm used for signing data.
|
|
#[derive(Clone, Debug, Eq, Hash, PartialEq, AsRefStr, DisplayAsRefStr)]
|
|
#[ruma_enum(rename_all = "snake_case")]
|
|
pub enum Algorithm {
|
|
/// The Ed25519 digital signature algorithm.
|
|
Ed25519,
|
|
}
|
|
|
|
/// 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];
|
|
|
|
if !version.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_') {
|
|
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 tests {
|
|
use std::collections::BTreeMap;
|
|
|
|
use base64::{decode_config, STANDARD_NO_PAD};
|
|
use ring::signature::{Ed25519KeyPair as RingEd25519KeyPair, KeyPair as _};
|
|
use ruma_identifiers::RoomVersionId;
|
|
use serde_json::{from_str, to_string};
|
|
|
|
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 object = from_str(input).unwrap();
|
|
canonical_json(&object)
|
|
}
|
|
|
|
#[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".into(),
|
|
)
|
|
.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 = BTreeMap::new();
|
|
signature_set.insert("ed25519:1".into(), public_key_string());
|
|
|
|
let mut public_key_map = BTreeMap::new();
|
|
public_key_map.insert("domain".into(), 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".into(),
|
|
)
|
|
.unwrap();
|
|
|
|
let mut alpha_object = from_str(r#"{ "one": 1, "two": "Two" }"#).unwrap();
|
|
sign_json("domain", &key_pair, &mut alpha_object).unwrap();
|
|
|
|
assert_eq!(
|
|
to_string(&alpha_object).unwrap(),
|
|
r#"{"one":1,"signatures":{"domain":{"ed25519:1":"t6Ehmh6XTDz7qNWI0QI5tNPSliWLPQP/+Fzz3LpdCS7q1k2G2/5b5Embs2j4uG3ZeivejrzqSVoBcdocRpa+AQ"}},"two":"Two"}"#
|
|
);
|
|
|
|
let mut reverse_alpha_object =
|
|
from_str(r#"{ "two": "Two", "one": 1 }"#).expect("reverse_alpha should serialize");
|
|
sign_json("domain", &key_pair, &mut reverse_alpha_object).unwrap();
|
|
|
|
assert_eq!(
|
|
to_string(&reverse_alpha_object).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 = BTreeMap::new();
|
|
signature_set.insert("ed25519:1".into(), public_key_string());
|
|
|
|
let mut public_key_map = BTreeMap::new();
|
|
public_key_map.insert("domain".into(), 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 = BTreeMap::new();
|
|
signature_set.insert("ed25519:1".into(), public_key_string());
|
|
|
|
let mut public_key_map = BTreeMap::new();
|
|
public_key_map.insert("domain".into(), 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".into(),
|
|
)
|
|
.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 object = from_str(json).unwrap();
|
|
hash_and_sign_event("domain", &key_pair, &mut object, &RoomVersionId::Version5).unwrap();
|
|
|
|
assert_eq!(
|
|
to_string(&object).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".into(),
|
|
)
|
|
.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 object = from_str(json).unwrap();
|
|
hash_and_sign_event("domain", &key_pair, &mut object, &RoomVersionId::Version5).unwrap();
|
|
|
|
assert_eq!(
|
|
to_string(&object).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 = BTreeMap::new();
|
|
signature_set.insert("ed25519:1".into(), public_key_string());
|
|
|
|
let mut public_key_map = BTreeMap::new();
|
|
public_key_map.insert("domain".into(), 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, &RoomVersionId::Version5).is_ok());
|
|
}
|
|
}
|