From 2675cfbb09a3c4a95a103f2c34e4217a1759b4ff Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 3 Jul 2026 16:20:04 +0100 Subject: [PATCH 01/13] feat(crypto): Add `X509Signature` type to the signature system Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- Cargo.lock | 134 +++++++++++++++++- crates/matrix-sdk-crypto/Cargo.toml | 5 + crates/matrix-sdk-crypto/src/olm/utility.rs | 4 +- .../src/types/signatures/mod.rs | 4 + .../src/types/signatures/signature.rs | 23 +++ .../src/types/signatures/x509_signature.rs | 107 ++++++++++++++ 6 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 crates/matrix-sdk-crypto/src/types/signatures/x509_signature.rs diff --git a/Cargo.lock b/Cargo.lock index 59846b4a38a..8c4c3f71ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,6 +867,18 @@ dependencies = [ "cc", ] +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codspeed" version = "4.2.1" @@ -1410,9 +1422,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1520,6 +1546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -2028,6 +2055,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.5" @@ -3083,6 +3116,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -3511,6 +3547,7 @@ dependencies = [ "bs58", "byteorder", "cfg-if", + "cms", "ctr", "eyeball", "futures-core", @@ -3530,6 +3567,7 @@ dependencies = [ "proptest", "rand 0.10.1", "rmp-serde", + "rsa", "ruma", "serde", "serde_json", @@ -4114,6 +4152,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -4131,6 +4185,26 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4330,6 +4404,15 @@ dependencies = [ "digest", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4484,6 +4567,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4650,7 +4744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -5131,6 +5225,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.2" @@ -5838,6 +5952,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest", "rand_core 0.6.4", ] @@ -5922,6 +6037,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" @@ -8105,6 +8226,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + [[package]] name = "xshell" version = "0.2.2" diff --git a/crates/matrix-sdk-crypto/Cargo.toml b/crates/matrix-sdk-crypto/Cargo.toml index 46fb4cfc0c0..abb76f76a06 100644 --- a/crates/matrix-sdk-crypto/Cargo.toml +++ b/crates/matrix-sdk-crypto/Cargo.toml @@ -44,6 +44,9 @@ testing = ["matrix-sdk-test"] # https://github.com/matrix-org/matrix-spec-proposals/pull/4385 experimental-push-secrets = [] +# Enable experirmental support for verifying user identities via X.509 certificates +experimental-x509-identity-verification = ["dep:cms", "dep:rsa", "sha2/oid"] + [dependencies] aes = { version = "0.8.4", default-features = false } aquamarine.workspace = true @@ -52,6 +55,7 @@ async-trait.workspace = true bs58 = { version = "0.5.1", default-features = false, features = ["std"] } byteorder.workspace = true cfg-if.workspace = true +cms = { version = "0.2.3", default-features = false, features = ["std"], optional = true } ctr = { version = "0.9.2", default-features = false } eyeball.workspace = true futures-core.workspace = true @@ -66,6 +70,7 @@ matrix-sdk-test = { workspace = true, optional = true } # feature = testing onl pbkdf2.workspace = true rand.workspace = true rmp-serde.workspace = true +rsa = { version = "0.9.10", default-features = false, optional = true } ruma = { workspace = true, features = ["rand", "unstable-msc3814"] } serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true diff --git a/crates/matrix-sdk-crypto/src/olm/utility.rs b/crates/matrix-sdk-crypto/src/olm/utility.rs index d5eeb3492b0..7f1f750b84e 100644 --- a/crates/matrix-sdk-crypto/src/olm/utility.rs +++ b/crates/matrix-sdk-crypto/src/olm/utility.rs @@ -23,7 +23,7 @@ use crate::{ types::{CrossSigningKey, DeviceKeys, Signature, Signatures, SignedKey}, }; -fn to_signable_json(mut value: CanonicalJsonValue) -> Result { +pub(crate) fn to_signable_json(mut value: CanonicalJsonValue) -> Result { let json_object = value.as_object_mut().ok_or(SignatureError::NotAnObject)?; let _ = json_object.remove("signatures"); let _ = json_object.remove("unsigned"); @@ -154,7 +154,7 @@ fn verify_signature( match s { Ok(Signature::Ed25519(s)) => Ok(public_key.verify(canonical_json.as_bytes(), s)?), - Ok(Signature::Other(_)) => Err(SignatureError::UnsupportedAlgorithm), + Ok(_) => Err(SignatureError::UnsupportedAlgorithm), Err(_) => Err(SignatureError::InvalidSignature), } } diff --git a/crates/matrix-sdk-crypto/src/types/signatures/mod.rs b/crates/matrix-sdk-crypto/src/types/signatures/mod.rs index ca8b75750ed..8c323ebf3f2 100644 --- a/crates/matrix-sdk-crypto/src/types/signatures/mod.rs +++ b/crates/matrix-sdk-crypto/src/types/signatures/mod.rs @@ -15,6 +15,8 @@ limitations under the License. */ mod signature; +#[cfg(feature = "experimental-x509-identity-verification")] +mod x509_signature; use std::collections::{BTreeMap, btree_map::IntoIter}; @@ -23,6 +25,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use vodozemac::Ed25519Signature; pub use self::signature::Signature; +#[cfg(feature = "experimental-x509-identity-verification")] +pub use self::x509_signature::{X509_SIGNATURE_ALGORITHM, X509Signature}; /// Represents a signature that could not be decoded. /// diff --git a/crates/matrix-sdk-crypto/src/types/signatures/signature.rs b/crates/matrix-sdk-crypto/src/types/signatures/signature.rs index cf50a861d36..87450976267 100644 --- a/crates/matrix-sdk-crypto/src/types/signatures/signature.rs +++ b/crates/matrix-sdk-crypto/src/types/signatures/signature.rs @@ -18,6 +18,8 @@ use ruma::DeviceKeyAlgorithm; use vodozemac::Ed25519Signature; use crate::types::InvalidSignature; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::types::{X509_SIGNATURE_ALGORITHM, X509Signature}; /// Represents a potentially decoded signature (but *not* a validated one). /// @@ -33,6 +35,9 @@ use crate::types::InvalidSignature; pub enum Signature { /// A Ed25519 digital signature. Ed25519(Ed25519Signature), + /// An X.509 digital signature. + #[cfg(feature = "experimental-x509-identity-verification")] + X509(X509Signature), /// A digital signature in an unsupported algorithm. The raw signature bytes /// are represented as a base64-encoded string. Other(String), @@ -51,6 +56,15 @@ impl Signature { DeviceKeyAlgorithm::Ed25519 => Ed25519Signature::from_base64(&s) .map(|s| s.into()) .map_err(|_| InvalidSignature { source: s }), + + #[cfg(feature = "experimental-x509-identity-verification")] + DeviceKeyAlgorithm::_Custom(_) if algorithm == X509_SIGNATURE_ALGORITHM.into() => { + use std::str::FromStr; + X509Signature::from_str(&s) + .map(|s| s.into()) + .map_err(|_| InvalidSignature { source: s }) + } + _ => Ok(Signature::Other(s)), } } @@ -59,6 +73,8 @@ impl Signature { pub fn to_base64(&self) -> String { match self { Signature::Ed25519(s) => s.to_base64(), + #[cfg(feature = "experimental-x509-identity-verification")] + Signature::X509(s) => s.to_string(), Signature::Other(s) => s.to_owned(), } } @@ -70,6 +86,13 @@ impl From for Signature { } } +#[cfg(feature = "experimental-x509-identity-verification")] +impl From for Signature { + fn from(signature: X509Signature) -> Self { + Self::X509(signature) + } +} + #[cfg(test)] mod test { use super::*; diff --git a/crates/matrix-sdk-crypto/src/types/signatures/x509_signature.rs b/crates/matrix-sdk-crypto/src/types/signatures/x509_signature.rs new file mode 100644 index 00000000000..0f50572f243 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/types/signatures/x509_signature.rs @@ -0,0 +1,107 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +use std::{ + fmt::{Debug, Display, Formatter}, + str::FromStr, +}; + +use cms::{ + cert::x509::{ + der, + der::{DecodePem, EncodePem}, + }, + content_info::ContentInfo, +}; + +/// Signature algorithm used to represent an X.509 signature. For use with +/// `DeviceKeyAlgorithm::from`. +pub const X509_SIGNATURE_ALGORITHM: &str = "io.element.x509"; + +/// An X.509 signature and certificate chain +#[derive(Clone, PartialEq, Eq)] +pub struct X509Signature(ContentInfoWrapper); + +impl X509Signature { + /// A new X.509 signature + pub fn new(content: ContentInfo) -> Self { + Self(ContentInfoWrapper(content)) + } + + /// Return the CMS `ContentInfo` object that is the body of this signature + pub fn get_signature(&self) -> &ContentInfo { + &self.0.0 + } +} + +// The debug format of `ContentInfo` is a very verbose list of each byte inside +// the content, so we define our own debug format which is just the +// stringification. +impl Debug for X509Signature { + fn fmt(&self, fmt: &mut Formatter<'_>) -> std::fmt::Result { + fmt.debug_tuple("X509Signature").field(&self.to_string()).finish() + } +} + +impl FromStr for X509Signature { + type Err = der::Error; + + /// Parse an X509 signature from the string format that is used to represent + /// it in a Matrix `signatures` object. + fn from_str(s: &str) -> Result { + let wrapper = ContentInfoWrapper::from_pem(s.as_bytes())?; + Ok(Self(wrapper)) + } +} + +impl Display for X509Signature { + /// Encode an X509 signature into the string format that is used to + /// represent it in a Matrix `signatures` object. + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + // Given that we've either constructed this object ourselves, or successfully + // parsed it from PEM, I don't think it's possible for encoding to fail. + self.0.to_pem(der::pem::LineEnding::LF).expect("Failed to encode an X.509 signature") + ) + } +} + +/// Workaround for https://github.com/RustCrypto/formats/issues/2352 +/// +/// A wrapper for [`ContentInfo`] which implements [`der::pem::PemLabel`] (as +/// well as proxying [`der::Encode`] and [`der::Decode`]) and can therefore be +/// used with [`der::EncodePem`] and [`der::DecodePem`]. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ContentInfoWrapper(ContentInfo); + +impl der::pem::PemLabel for ContentInfoWrapper { + // Per RFC7468, the PEM label for an RFC5652 ContentInfo is "CMS". + const PEM_LABEL: &str = "CMS"; +} +impl<'a> der::Decode<'a> for ContentInfoWrapper { + fn decode>(decoder: &mut R) -> der::Result { + Ok(ContentInfoWrapper(ContentInfo::decode(decoder)?)) + } +} +impl der::Encode for ContentInfoWrapper { + fn encoded_len(&self) -> der::Result { + self.0.encoded_len() + } + fn encode(&self, encoder: &mut impl der::Writer) -> der::Result<()> { + self.0.encode(encoder) + } +} From 85f943737db3181ac50aadbe5438513a9ce1ce09 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 12:43:09 +0100 Subject: [PATCH 02/13] feat(crypto): Add `x509` module with signer/verifier traits Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/error.rs | 5 + crates/matrix-sdk-crypto/src/lib.rs | 1 + crates/matrix-sdk-crypto/src/x509/errors.rs | 136 ++++++ crates/matrix-sdk-crypto/src/x509/mod.rs | 27 ++ .../src/x509/raw_x509_signature.rs | 430 ++++++++++++++++++ .../matrix-sdk-crypto/src/x509/x509_signer.rs | 77 ++++ .../matrix-sdk-crypto/src/x509/x509_verify.rs | 237 ++++++++++ 7 files changed, 913 insertions(+) create mode 100644 crates/matrix-sdk-crypto/src/x509/errors.rs create mode 100644 crates/matrix-sdk-crypto/src/x509/mod.rs create mode 100644 crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs create mode 100644 crates/matrix-sdk-crypto/src/x509/x509_signer.rs create mode 100644 crates/matrix-sdk-crypto/src/x509/x509_verify.rs diff --git a/crates/matrix-sdk-crypto/src/error.rs b/crates/matrix-sdk-crypto/src/error.rs index fd986ebb7f9..0a721460675 100644 --- a/crates/matrix-sdk-crypto/src/error.rs +++ b/crates/matrix-sdk-crypto/src/error.rs @@ -292,6 +292,11 @@ pub enum SignatureError { #[error(transparent)] InvalidKey(#[from] vodozemac::KeyError), + /// The message could not be signed with X.509 + #[cfg(feature = "experimental-x509-identity-verification")] + #[error("the message could not be signed with X.509 {0}")] + X509SigningError(String), + /// The signature could not be decoded. #[error("the given signature is not valid and can't be decoded")] InvalidSignature, diff --git a/crates/matrix-sdk-crypto/src/lib.rs b/crates/matrix-sdk-crypto/src/lib.rs index b5bb02dac07..2e917a0b6c7 100644 --- a/crates/matrix-sdk-crypto/src/lib.rs +++ b/crates/matrix-sdk-crypto/src/lib.rs @@ -33,6 +33,7 @@ pub mod store; pub mod types; mod utilities; mod verification; +pub mod x509; #[cfg(any(test, feature = "testing"))] /// Testing facilities and helpers for crypto tests diff --git a/crates/matrix-sdk-crypto/src/x509/errors.rs b/crates/matrix-sdk-crypto/src/x509/errors.rs new file mode 100644 index 00000000000..6a8e82d9f72 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/errors.rs @@ -0,0 +1,136 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use cms::cert::x509::{der, spki::ObjectIdentifier}; +use thiserror::Error; + +#[cfg(doc)] +use crate::{types::X509Signature, x509::RawX509Signature}; + +/// An error resulting from converting a [`RawX509Signature`] into an +/// [`X509Signature`]. +#[derive(Error, Debug)] +pub enum IntoX509SignatureError { + #[error("failed to parse certificate chain: {0}")] + CertificateChainParseError(#[source] der::Error), + + #[error("empty certificate chain")] + EmptyCertChain, + + #[error("failed to parse extensions in leaf certificate: {0}")] + LeafCertificateExtensionParseError(#[source] der::Error), + + #[error("failed to parse extensions in last certificate: {0}")] + LastCertificateExtensionParseError(#[source] der::Error), + + #[error("no SubjectKeyIdentifier in leaf certificate")] + LeafCertificateMissingSubjectKeyIdentifier, + + #[error("no AuthorityKeyIdentifier in last certificate")] + LastCertificateMissingAuthorityKeyIdentifier, + + #[error("no KeyIdentifier in AuthorityKeyIdentifier in last certificate")] + LastCertificateMissingKeyIdentifierInAuthorityKeyIdentifier, +} + +/// An error resulting from checking the fields in an [`X509Signature`] when +/// converting to a [`RawX509Signature`]. +#[derive(Error, Debug)] +pub enum RawX509SignatureParseError { + #[error("ContentInfo contentType is {}, not ID_SIGNED_DATA ({})", .0.actual, .0.expected)] + UnexpectedContentInfoContentType(OidMismatch), + + #[error("EncapsulatedContentInfo eContentType is {}, not ID_DATA ({})", .0.actual, .0.expected)] + UnexpectedEncapsulatedContentInfoContentType(OidMismatch), + + #[error("EncapsulatedContentInfo eContent is not NULL")] + EncapsulatedContentNotNull, + + #[error("could not parse content of ContentInfo as SignedData")] + ContentInfoParseError(#[source] der::Error), + + #[error("no certificate chain in SignedData")] + NoCertificateChainInSignedData, + + #[error("empty certificate chain in SignedData")] + EmptyCertificateChainInSignedData, + + #[error("non-X.509 certificate in certificate chain")] + NonX509CertificateInCertificateChain, + + #[error("failed to parse extensions in leaf certificate: {0}")] + LeafCertificateExtensionParseError(#[source] der::Error), + + #[error("no SubjectKeyIdentifier in leaf certificate")] + LeafCertificateMissingSubjectKeyIdentifier, + + #[error("SignedData contains certificate revocation lists")] + SignedDataContainsCrls, + + #[error("SignerInfos list is empty")] + EmptySignerInfos, + + #[error("SignerInfos list contains multiple entries")] + MultipleSignerInfos, + + #[error("SignerIdentifier is not a SubjectKeyIdentifier")] + SignerIdentifierNotSubjectKeyIdentifier, + + #[error("SignerIdentifier does not match SubjectKeyIdentifier of leaf certificate")] + SignerIdentifierMismatch, + + #[error("SignerInfo contains signed attributes")] + SignerInfoContainsSignedAttrs, + + #[error("unsupported signature algorithm: {}", .0.actual)] + UnsupportedSignatureAlgorithm(OidMismatch), + + #[error("unsupported digest algorithm: {}", .0.actual)] + UnsupportedDigestAlgorithm(OidMismatch), + + #[error("digest algorithm parameters are not NULL")] + DigestAlgorithmParametersNotNull, + + #[error("SignerInfo.signature_algorithm.parameters not set")] + SignatureAlgorithmParametersNotSet, + + #[error("could not parse SignatureAlgorithm.parameters")] + SignatureAlgorithmParametersParseError(#[source] der::Error), + + #[error("unsupported SignatureAlgorithm hash algorithm: {}", .0.actual)] + UnsupportedSignatureAlgorithmHash(OidMismatch), + + #[error("unsupported SignatureAlgorithm mask generation function: {}", .0.actual)] + UnsupportedSignatureAlgorithmMaskGen(OidMismatch), + + #[error("SignatureAlgorithm mask generation function parameters not set")] + SignatureAlgorithmMaskGenParametersNotSet, + + #[error( + "unsupported SignatureAlgorithm mask generation function hash algorithm: {}", .0.actual + )] + UnsupportedSignatureAlgorithmMaskGenHash(OidMismatch), + + #[error("SignatureAlgorithm salt length is not 64: {0}")] + UnsupportedSignatureAlgorithmSaltLen(u8), +} + +/// Helper type for some of the error codes in [`RawX509SignatureParseError`]: +/// reflects that an OID in the signature structure did not match the expected +/// value. +#[derive(Debug)] +pub struct OidMismatch { + pub actual: ObjectIdentifier, + pub expected: ObjectIdentifier, +} diff --git a/crates/matrix-sdk-crypto/src/x509/mod.rs b/crates/matrix-sdk-crypto/src/x509/mod.rs new file mode 100644 index 00000000000..bb408d94953 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/mod.rs @@ -0,0 +1,27 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![cfg(feature = "experimental-x509-identity-verification")] + +//! Types and traits for verification of users and devices using X.509 keys and +//! certificates. + +mod errors; +mod raw_x509_signature; +mod x509_signer; +mod x509_verify; + +pub use raw_x509_signature::RawX509Signature; +pub use x509_signer::{RawX509Signer, X509Signer}; +pub use x509_verify::{RawX509Verifier, X509Verifier}; diff --git a/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs b/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs new file mode 100644 index 00000000000..dee89820ba5 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs @@ -0,0 +1,430 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use cms::{ + cert::{ + CertificateChoices, + x509::{ + Certificate, der, + der::{ + EncodePem, asn1::SetOfVec, oid as const_oid, pem::LineEnding, + referenced::OwnedToRef, + }, + ext::pkix::{AuthorityKeyIdentifier, SubjectKeyIdentifier}, + spki::{AlgorithmIdentifierOwned, ObjectIdentifier}, + }, + }, + content_info::{CmsVersion, ContentInfo}, + signed_data::{ + EncapsulatedContentInfo, SignatureValue, SignedData, SignerIdentifier, SignerInfo, + SignerInfos, + }, +}; +use rsa::pkcs1::RsaPssParams; +use ruma::OwnedDeviceId; +use vodozemac::base64_encode; + +#[cfg(doc)] +use crate::x509::{RawX509Signer, RawX509Verifier}; +use crate::{ + types::X509Signature, + x509::errors::{IntoX509SignatureError, OidMismatch, RawX509SignatureParseError}, +}; + +/// The object we receive from [`RawX509Signer`], and pass to +/// [`RawX509Verifier`]. +/// +/// A simplified representation of the data in the signature object. +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone)] +pub struct RawX509Signature { + /// The raw bytes of the signature. + pub signature_bytes: Vec, + + /// The PEM-encoded certificate chain, starting with the device's own + /// certificate, followed by intermediate certificates. + pub certificate_chain: String, + + /// The algorithm that the signer used to construct the signature. + pub signature_scheme: X509SignatureScheme, +} + +impl RawX509Signature { + /// Convert this signing result into an [`X509Signature`] containing a CMS + /// `SignedData` object. + pub fn into_x509_signature( + self, + ) -> Result<(OwnedDeviceId, X509Signature), IntoX509SignatureError> { + let cert_chain = Certificate::load_pem_chain(self.certificate_chain.as_bytes()) + .map_err(IntoX509SignatureError::CertificateChainParseError)?; + + let (first_cert, last_cert) = match cert_chain.as_slice() { + [] => return Err(IntoX509SignatureError::EmptyCertChain), + [single] => (single, single), + [first, .., last] => (first, last), + }; + + // The subject key identifier of the cert for the key that we signed with. + let leaf_ski = first_cert + .tbs_certificate + .get::() + .map_err(IntoX509SignatureError::LeafCertificateExtensionParseError)? + .ok_or(IntoX509SignatureError::LeafCertificateMissingSubjectKeyIdentifier)? + .1; + + // The authority key identifier of the highest cert in the chain, which should + // be the subject key identifier of the CA. + let authority_key_identifier = last_cert + .tbs_certificate + .get::() + .map_err(IntoX509SignatureError::LastCertificateExtensionParseError)? + .ok_or(IntoX509SignatureError::LastCertificateMissingAuthorityKeyIdentifier)? + .1; + + let authority_key_identifier_bytes = authority_key_identifier.key_identifier.ok_or( + IntoX509SignatureError::LastCertificateMissingKeyIdentifierInAuthorityKeyIdentifier, + )?; + + let signer_info = SignerInfo { + // RFC 5652 § 5.3: version is the syntax version number. If the SignerIdentifier is + // the CHOICE issuerAndSerialNumber, then the version MUST be 1. If + // the SignerIdentifier is subjectKeyIdentifier, then the version MUST be 3. + version: CmsVersion::V3, + + sid: SignerIdentifier::SubjectKeyIdentifier(leaf_ski), + digest_alg: self.signature_scheme.get_digest_algorithm(), + signed_attrs: None, // Not required if EncapsulatedContentInfo is id-data + signature_algorithm: self.signature_scheme.get_signature_algorithm(), + signature: SignatureValue::new(self.signature_bytes).expect( + // This can only fail by failing to encode the length of the bytes as a usize + "Unable to encode signature bytes as SignatureValue", + ), + unsigned_attrs: None, + }; + + let signed_data = SignedData { + // RFC 5652 § 5.1. SignedData Type + // IF ((certificates is present) AND + // (any certificates with a type of other are present)) OR + // ((crls is present) AND + // (any crls with a type of other are present)) + // THEN version MUST be 5 + // ELSE + // IF (certificates is present) AND + // (any version 2 attribute certificates are present) + // THEN version MUST be 4 + // ELSE + // IF ((certificates is present) AND + // (any version 1 attribute certificates are present)) OR + // (any SignerInfo structures are version 3) OR + // (encapContentInfo eContentType is other than id-data) + // THEN version MUST be 3 + // ELSE version MUST be 1 + // + // TL;DR: since our SignerInfo is v3, we need a v3 SignedData. + version: CmsVersion::V3, + digest_algorithms: vec![signer_info.digest_alg.clone()].into_set_of_vec(), + encap_content_info: EncapsulatedContentInfo { + econtent_type: const_oid::db::rfc5911::ID_DATA, + econtent: None, + }, + certificates: Some( + cert_chain + .into_iter() + .map(CertificateChoices::Certificate) + .collect::>() + .into_set_of_vec() + .into(), + ), + crls: None, + signer_infos: SignerInfos(vec![signer_info].into_set_of_vec()), + }; + + let signature = X509Signature::new(ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: der::Any::encode_from(&signed_data) + .expect("Unable to encode SignedData as DER"), + }); + + let device_id = OwnedDeviceId::from(base64_encode(authority_key_identifier_bytes)); + + Ok((device_id, signature)) + } +} + +/// Helper trait for building [`SetOfVec`] whilst suppressing the impossible +/// error. +trait IntoSetOfVec { + fn into_set_of_vec(self) -> SetOfVec; +} + +impl IntoSetOfVec for Vec { + fn into_set_of_vec(self) -> SetOfVec { + // Building a SetOfVec has to calculate the lengths of each of the entries, + // which is theoretically fallible if the lengths cannot be represented as a + // `usize`. + // + // In practice, I can't see why it would fail. + self.try_into().expect("Unable to construct SetOfVec") + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RawX509SignatureAndFirstCertificate { + pub raw_x509signature: RawX509Signature, + pub leaf_cert: Certificate, +} + +impl TryFrom<&X509Signature> for RawX509SignatureAndFirstCertificate { + type Error = RawX509SignatureParseError; + + fn try_from(value: &X509Signature) -> Result { + let content_info = value.get_signature(); + + assert_oid_matches(&content_info.content_type, &const_oid::db::rfc5911::ID_SIGNED_DATA) + .map_err(RawX509SignatureParseError::UnexpectedContentInfoContentType)?; + + let data: SignedData = content_info + .content + .decode_as() + .map_err(RawX509SignatureParseError::ContentInfoParseError)?; + + data.try_into() + } +} + +impl TryFrom for RawX509SignatureAndFirstCertificate { + type Error = RawX509SignatureParseError; + + fn try_from(data: SignedData) -> Result { + // TODO? check version? + // TODO? check digest_algorithms? + let encapsulated_content_info = &data.encap_content_info; + check_encapsulated_content_info(encapsulated_content_info)?; + + let certificates: Vec<_> = data + .certificates + .ok_or(RawX509SignatureParseError::NoCertificateChainInSignedData)? + .0 + .into_vec() + .into_iter() + .map(|cert| match cert { + CertificateChoices::Certificate(c) => Ok(c), + CertificateChoices::Other(_) => { + Err(RawX509SignatureParseError::NonX509CertificateInCertificateChain) + } + }) + .collect::>()?; + + let cert_pems = certificates + .iter() + .map(|cert| { + cert.to_pem(LineEnding::CRLF).expect("Unable to format certificate chain as PEMs") + }) + .collect::>() + .join(""); + + let leaf_cert = certificates + .into_iter() + .next() + .ok_or(RawX509SignatureParseError::EmptyCertificateChainInSignedData)?; + + let leaf_ski = leaf_cert + .tbs_certificate + .get::() + .map_err(RawX509SignatureParseError::LeafCertificateExtensionParseError)? + .ok_or(RawX509SignatureParseError::LeafCertificateMissingSubjectKeyIdentifier)? + .1; + + if data.crls.is_some() { + return Err(RawX509SignatureParseError::SignedDataContainsCrls); + } + + let mut signer_infos = data.signer_infos.0.into_vec(); + let signer_info = signer_infos.pop().ok_or(RawX509SignatureParseError::EmptySignerInfos)?; + if !signer_infos.is_empty() { + return Err(RawX509SignatureParseError::MultipleSignerInfos); + } + + let (signature_scheme, signature_bytes) = parse_signer_info(signer_info, &leaf_ski)?; + let raw_x509signature = + RawX509Signature { signature_bytes, certificate_chain: cert_pems, signature_scheme }; + Ok(RawX509SignatureAndFirstCertificate { raw_x509signature, leaf_cert }) + } +} + +fn check_encapsulated_content_info( + encapsulated_content_info: &EncapsulatedContentInfo, +) -> Result<(), RawX509SignatureParseError> { + assert_oid_matches(&encapsulated_content_info.econtent_type, &const_oid::db::rfc5911::ID_DATA) + .map_err(RawX509SignatureParseError::UnexpectedEncapsulatedContentInfoContentType)?; + if encapsulated_content_info.econtent.is_some() { + return Err(RawX509SignatureParseError::EncapsulatedContentNotNull); + } + Ok(()) +} + +/// Verify the given [`SignerInfo`], checking its signer identifier matches +/// the given `expected_ski`. +/// +/// # Returns +/// +/// - The signature scheme used for the signature. +/// - The raw bytes of the signature. +fn parse_signer_info( + signer_info: SignerInfo, + expected_ski: &SubjectKeyIdentifier, +) -> Result<(X509SignatureScheme, Vec), RawX509SignatureParseError> { + // TODO: check version? + match &signer_info.sid { + SignerIdentifier::IssuerAndSerialNumber(_) => { + return Err(RawX509SignatureParseError::SignerIdentifierNotSubjectKeyIdentifier); + } + SignerIdentifier::SubjectKeyIdentifier(ski) => { + if *ski != *expected_ski { + return Err(RawX509SignatureParseError::SignerIdentifierMismatch); + } + } + } + + if signer_info.signed_attrs.is_some() { + return Err(RawX509SignatureParseError::SignerInfoContainsSignedAttrs); + } + + let signature_scheme = map_signer_info_algorithms_to_signature_scheme( + &signer_info.digest_alg, + &signer_info.signature_algorithm, + )?; + + let signature_bytes = signer_info.signature.into_bytes(); + Ok((signature_scheme, signature_bytes)) +} + +/// Given the digest and signature algorithms from a `SignerInfo` structure, map +/// them to one of our supported [`X509SignatureScheme`]s. +fn map_signer_info_algorithms_to_signature_scheme( + digest_alg: &AlgorithmIdentifierOwned, + signature_algorithm: &AlgorithmIdentifierOwned, +) -> Result { + // For now, we only support RsaPssSha512 + assert_oid_matches(&signature_algorithm.oid, &const_oid::db::rfc5912::ID_RSASSA_PSS) + .map_err(RawX509SignatureParseError::UnsupportedSignatureAlgorithm)?; + + assert_oid_matches(&digest_alg.oid, &const_oid::db::rfc5912::ID_SHA_512) + .map_err(RawX509SignatureParseError::UnsupportedDigestAlgorithm)?; + assert_digest_alg_params_null_or_absent(&digest_alg.parameters.owned_to_ref())?; + + let signature_algorithm_params: RsaPssParams<'_> = signature_algorithm + .parameters + .as_ref() + .ok_or(RawX509SignatureParseError::SignatureAlgorithmParametersNotSet)? + .decode_as() + .map_err(RawX509SignatureParseError::SignatureAlgorithmParametersParseError)?; + + assert_oid_matches(&signature_algorithm_params.hash.oid, &const_oid::db::rfc5912::ID_SHA_512) + .map_err(RawX509SignatureParseError::UnsupportedSignatureAlgorithmHash)?; + assert_digest_alg_params_null_or_absent(&signature_algorithm_params.hash.parameters)?; + + assert_oid_matches(&signature_algorithm_params.mask_gen.oid, &const_oid::db::rfc5912::ID_MGF_1) + .map_err(RawX509SignatureParseError::UnsupportedSignatureAlgorithmMaskGen)?; + + let mask_gen_params = signature_algorithm_params + .mask_gen + .parameters + .ok_or(RawX509SignatureParseError::SignatureAlgorithmMaskGenParametersNotSet)?; + + assert_oid_matches(&mask_gen_params.oid, &const_oid::db::rfc5912::ID_SHA_512) + .map_err(RawX509SignatureParseError::UnsupportedSignatureAlgorithmMaskGenHash)?; + assert_digest_alg_params_null_or_absent(&mask_gen_params.parameters)?; + + if signature_algorithm_params.salt_len != 64 { + return Err(RawX509SignatureParseError::UnsupportedSignatureAlgorithmSaltLen( + signature_algorithm_params.salt_len, + )); + } + + Ok(X509SignatureScheme::RsaPssSha512) +} + +/// The algorithm that was used to construct the signature. +/// +/// This might be extended in future, but for now we only support +/// RsaPssSha512. +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum X509SignatureScheme { + /// SHA-512 message digest, with RSASSA-PSS signature scheme (aka RSA-PSS), + /// per [RFC 8017 § 8.1]. + /// + /// Not to be confused with RSA-PKCS1, which is incompatible. + /// + /// [RFC 8017 § 8.1]: https://www.rfc-editor.org/info/rfc8017/#section-8.1 + RsaPssSha512, +} + +impl X509SignatureScheme { + /// Build a CMS `DigestAlgorithmIdentifier` (as defined in [RFC 5652 § + /// 4.1.1.2]) for the digest algorithm used by this signature algorithm. + /// + /// (Actually a `DigestAlgorithmIdentifier` is the same as a regular + /// `AlgorithmIdentifier`, though obviously we expect it to identify a + /// digest algorithm like SHA-512.) + pub fn get_digest_algorithm(&self) -> AlgorithmIdentifierOwned { + use der::oid::AssociatedOid; + + match self { + X509SignatureScheme::RsaPssSha512 => AlgorithmIdentifierOwned { + oid: sha2::Sha512::OID, + parameters: Some(der::Any::null()), + }, + } + } + + /// Build an X.509 `AlgorithmIdentifier` (as defined in [RFC 5280 § + /// 4.1.1.2]) for this signature scheme. + /// + /// [RFC 5280 § 4.1.1.2]: https://tools.ietf.org/html/rfc5280#section-4.1.1.2 + pub fn get_signature_algorithm(&self) -> AlgorithmIdentifierOwned { + match self { + X509SignatureScheme::RsaPssSha512 => { + // If you are interested in the details of all the fields in RsaPssParams, + // then https://crypto.stackexchange.com/a/58708 is a decent primer. + // Fortunately the RustCrypto folks have done the legwork for us. + rsa::pss::get_default_pss_signature_algo_id::() + .expect("Unable to get AlgorithmIdentifier for RSA_PSS") + } + } + } +} + +fn assert_oid_matches( + actual: &ObjectIdentifier, + expected: &ObjectIdentifier, +) -> Result<(), OidMismatch> { + if *actual == *expected { + Ok(()) + } else { + Err(OidMismatch { actual: *actual, expected: *expected }) + } +} + +fn assert_digest_alg_params_null_or_absent( + digest_alg_params: &Option>, +) -> Result<(), RawX509SignatureParseError> { + match digest_alg_params { + None => Ok(()), + Some(x) if x.is_null() => Ok(()), + Some(_) => Err(RawX509SignatureParseError::DigestAlgorithmParametersNotNull), + } +} diff --git a/crates/matrix-sdk-crypto/src/x509/x509_signer.rs b/crates/matrix-sdk-crypto/src/x509/x509_signer.rs new file mode 100644 index 00000000000..a66aabfd6b6 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/x509_signer.rs @@ -0,0 +1,77 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use ruma::{DeviceKeyId, UserId, canonical_json::to_canonical_value}; + +use crate::{ + SignatureError, + olm::utility::to_signable_json, + types::{CrossSigningKey, X509_SIGNATURE_ALGORITHM}, + x509::raw_x509_signature::RawX509Signature, +}; + +/// Hold one of these if you want to sign cross-signing keys, and call +/// [`Self::sign_cross_signing_key`] to do it. +/// +/// Internally, this holds an implementation of [`RawX509Signer`] that does the +/// real work of signing things. This struct provides a convenient wrapper that +/// e.g. converts a cross-signing key to signable canonical JSON. +#[derive(Debug, Clone)] +pub struct X509Signer { + x509_sign: Arc, +} + +impl X509Signer { + /// Create a new `X509Signer` that wraps the supplied [`RawX509Signer`]. + pub fn new(x509_sign: Arc) -> Self { + Self { x509_sign } + } + + /// Add a signature to the given cross-signing key using our private X.509 + /// key. + pub fn sign_cross_signing_key( + &self, + signing_user_id: &UserId, + cross_signing_key: &mut CrossSigningKey, + ) -> Result<(), SignatureError> { + let json = to_signable_json(to_canonical_value(&cross_signing_key)?)?; + + let (device_id, signature) = + self.x509_sign.sign(json.as_bytes())?.into_x509_signature().map_err(|e| { + SignatureError::X509SigningError(format!( + "Error parsing response from RawX509Signer: {}", + e + )) + })?; + + cross_signing_key.signatures.add_signature( + signing_user_id.to_owned(), + DeviceKeyId::from_parts(X509_SIGNATURE_ALGORITHM.into(), &device_id), + signature, + ); + + Ok(()) + } +} + +/// A low-level interface for signing messages with a private key. We have Rust +/// and platform-specific implementations. +pub trait RawX509Signer: std::fmt::Debug + Send + Sync { + /// Create a signature for the given message using our private key + /// + /// Returns (key ID, signature) + fn sign(&self, message: &[u8]) -> Result; +} diff --git a/crates/matrix-sdk-crypto/src/x509/x509_verify.rs b/crates/matrix-sdk-crypto/src/x509/x509_verify.rs new file mode 100644 index 00000000000..19b532cf367 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/x509_verify.rs @@ -0,0 +1,237 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License + +use std::{fmt::Debug, sync::Arc}; + +use cms::cert::x509::{ + Certificate, + attr::AttributeValue, + der, + der::oid as const_oid, + ext::pkix::{SubjectAltName, name::GeneralName}, +}; +use ruma::{MatrixUri, OwnedUserId, UserId, matrix_uri::MatrixId}; +use tracing::info; + +use crate::{ + olm::SignedJsonObject, + types::Signature, + x509::raw_x509_signature::{RawX509Signature, RawX509SignatureAndFirstCertificate}, +}; + +/// Hold one of these if you want to verify X.509 signatures, and call +/// [`Self::verify_x509_signature`] to do it. +/// +/// Internally, this holds an implementation of [`RawX509Verifier`] that does +/// the real work of verifying things. This struct provides a convenient +/// wrapper. +#[derive(Debug, Clone)] +pub struct X509Verifier { + x509_verify: Arc, +} + +impl X509Verifier { + /// Create a new `X509Verifier` that wraps the supplied [`RawX509Verifier`]. + pub fn new(x509_verify: Arc) -> X509Verifier { + X509Verifier { x509_verify } + } + + /// Verify that the given object is signed with a certificate issued by a + /// trusted CA, and that the certificate was issued to the given user + /// ID. + pub(crate) fn verify_signed_object( + &self, + user_id: &UserId, + signed_object: &(impl SignedJsonObject + Debug), + ) -> bool { + info!(?signed_object, "X509: verify_signed_object()"); + + let Some(this_user_sigs) = signed_object.signatures().get(user_id) else { + info!("X509: verify_signed_object(): no signatures on object"); + return false; + }; + + let Ok(msg) = signed_object.to_canonical_json() else { + tracing::warn!("Unable to serialize object"); + return false; + }; + + for sig in this_user_sigs.values().flatten() { + if self.verify_x509_signature(user_id, &msg, sig) { + info!("X509: verify_signed_object(): verified X509 signature"); + return true; + } else { + tracing::warn!("X509: verify_signed_object(): X509 signature failed verification"); + } + } + false + } + + /// Check if the given signature is a valid X.509 signature for the given + /// message. + /// + /// Also validates that the certificate used for the signature is issued via + /// one of our trusted CAs, and was issued to the given user id. + pub fn verify_x509_signature(&self, user_id: &UserId, message: &str, sig: &Signature) -> bool { + let Signature::X509(sig) = sig else { + // Not an error: just the wrong type of signature. + return false; + }; + + let res: RawX509SignatureAndFirstCertificate = match sig.try_into() { + Ok(res) => res, + Err(e) => { + tracing::warn!( + "X509: verify_x509_signature(): unable to parse X509 signature: {}", + e + ); + return false; + } + }; + + // Before we pass over to the X.509 certificate verifier, check that the leaf + // certificate is valid for the given user_id. + if !cert_contains_user_id_or_equivalent_email(user_id, &res.leaf_cert) { + tracing::warn!("Verifying certificate user ID or email failed"); + return false; + } + + self.x509_verify.verify(message.as_bytes(), &res.raw_x509signature) + } +} + +fn cert_contains_user_id_or_equivalent_email(user_id: &UserId, certificate: &Certificate) -> bool { + // Check for a user ID in its SAN + if let Some(certificate_user_id) = get_user_id_from_certificate(certificate) { + if certificate_user_id == user_id { + return true; + } else { + tracing::warn!( + "Certificate not valid for this user. \ + Certificate user ID: {certificate_user_id}, \ + User ID: {user_id}", + ); + return false; + } + } + + // Otherwise, as a fallback, check for an email address + + tracing::info!("Certificate subject does not contain a user ID. Checking for email address"); + + let Some(certificate_email) = get_email_address_from_certificate(certificate) else { + tracing::warn!("Certificate subject does not contain an email address"); + return false; + }; + + let expected_email = map_user_id_to_email(user_id); + if certificate_email == expected_email { + true + } else { + tracing::warn!( + "Certificate not valid for this user. \ + Certificate email: {certificate_email}, \ + Expected email: {expected_email}, User ID: {user_id}", + ); + false + } +} + +/// Something that can verify an X.509 signature. +pub trait RawX509Verifier: Debug + Send + Sync { + /// Check if the given signature is a valid X.509 signature for the given + /// message. + /// + /// Also validates that the certificate used for the signature is issued via + /// one of our trusted CAs. + fn verify(&self, message: &[u8], signature: &RawX509Signature) -> bool; +} + +fn map_user_id_to_email(user_id: &UserId) -> String { + // TODO RAV: this is not a reliable way to map from user_ids to email addresses. + format!("{}@{}", user_id.localpart(), user_id.server_name()) +} + +/// Search this certificate's Subject Alternative Name for a URI that matches +/// the format of a Matrix URI that contains a valid Matrix user ID. +fn get_user_id_from_certificate(certificate: &Certificate) -> Option { + // If we have no SAN or SAN is not understood here, we definitely can't find a + // user ID. + let Ok(Some((_, san))) = certificate.tbs_certificate.get::() else { + return None; + }; + + /// Check whether a SAN contains a valid Matrix user ID + fn matrix_user_uri(alt_name: &GeneralName) -> Option { + // If it's a URI SAN type + if let GeneralName::UniformResourceIdentifier(uri) = alt_name { + // And it parses as a `matrix:...` URI + if let Ok(matrix_uri) = MatrixUri::parse(uri.as_str()) { + // And it's a user URI that produces a valid Matrix user ID + if let MatrixId::User(user_id) = matrix_uri.id() { + // Then return it + return Some(user_id.clone()); + } + } + } + + // Otherwise, we didn't find a user ID + None + } + + // If any name looks right, return it - otherwise None + san.0.iter().find_map(matrix_user_uri) +} + +fn get_email_address_from_certificate(certificate: &Certificate) -> Option { + // Check for an email address in the Subject Alternative Name + if let Ok(Some((_, san))) = certificate.tbs_certificate.get::() + && let Some(email) = san + .0 + .into_iter() + .find_map(|n| if let GeneralName::Rfc822Name(email) = n { Some(email) } else { None }) + { + return Some(email.as_str().to_owned()); + } + + // Otherwise, check for the (legacy) email address in the Subject + // Distinguished Name + let subject = &certificate.tbs_certificate.subject; + for atav in subject.0.iter().flat_map(|rdn| rdn.0.iter()) { + if atav.oid == const_oid::db::rfc3280::EMAIL_ADDRESS + && let Some(e) = get_attribute_value_as_string(&atav.value) + { + return Some(e.to_owned()); + } + } + + // Otherwise, nothing was found + None +} + +/// Attempt to parse the given X.501 Attribute as a string +fn get_attribute_value_as_string(value: &AttributeValue) -> Option<&str> { + use der::Tagged; + match value.tag() { + der::Tag::PrintableString => { + der::asn1::PrintableStringRef::try_from(value).ok().map(|s| s.as_str()) + } + der::Tag::Utf8String => der::asn1::Utf8StringRef::try_from(value).ok().map(|s| s.as_str()), + der::Tag::Ia5String => der::asn1::Ia5StringRef::try_from(value).ok().map(|s| s.as_str()), + der::Tag::TeletexString => { + der::asn1::TeletexStringRef::try_from(value).ok().map(|s| s.as_str()) + } + _ => None, + } +} From 6d792ea35bffaddccc0da3867e3a4aebb889257c Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 12:43:09 +0100 Subject: [PATCH 03/13] test(crypto): Test raw X.509 signature parsing; snapshots Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- .../src/x509/raw_x509_signature.rs | 39 + ..._raw_x509_signature__test__sig_to_raw.snap | 1377 +++++++++++++++++ .../matrix-sdk-crypto/src/x509/test_cms.pem | 63 + 3 files changed, 1479 insertions(+) create mode 100644 crates/matrix-sdk-crypto/src/x509/snapshots/matrix_sdk_crypto__x509__raw_x509_signature__test__sig_to_raw.snap create mode 100644 crates/matrix-sdk-crypto/src/x509/test_cms.pem diff --git a/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs b/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs index dee89820ba5..f0b68e98ae4 100644 --- a/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs +++ b/crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs @@ -428,3 +428,42 @@ fn assert_digest_alg_params_null_or_absent( Some(_) => Err(RawX509SignatureParseError::DigestAlgorithmParametersNotNull), } } + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use insta::assert_debug_snapshot; + + use super::*; + + /// Test parsing a known CMS structure into a + /// [`RawX509SignatureAndFirstCertificate`], and compare it against a + /// snapshot. + #[test] + fn test_sig_to_raw() { + const SIG: &str = include_str!("test_cms.pem"); + let x509signature = X509Signature::from_str(SIG).unwrap(); + let raw_signature: RawX509SignatureAndFirstCertificate = + (&x509signature).try_into().unwrap(); + assert_debug_snapshot!(raw_signature); + } + + /// We should be able to roundtrip a CMS structure into a + /// [`RawX509Signature`] and back again. + #[test] + fn test_roundtrip_sig_to_raw_and_back() { + const SIG: &str = include_str!("test_cms.pem"); + let x509signature = X509Signature::from_str(SIG).unwrap(); + let raw_and_certs: RawX509SignatureAndFirstCertificate = + (&x509signature).try_into().unwrap(); + let (device_id, roundtripped) = + raw_and_certs.raw_x509signature.into_x509_signature().unwrap(); + assert_eq!(roundtripped.to_string(), x509signature.to_string()); + + // The SKI of the CA cert is + // D8:5E:91:9A:17:F0:C3:5B:13:DB:75:42:7D:21:37:9A:DF:3E:96:11, which when + // base64-encoded is... + assert_eq!(device_id, "2F6Rmhfww1sT23VCfSE3mt8+lhE"); + } +} diff --git a/crates/matrix-sdk-crypto/src/x509/snapshots/matrix_sdk_crypto__x509__raw_x509_signature__test__sig_to_raw.snap b/crates/matrix-sdk-crypto/src/x509/snapshots/matrix_sdk_crypto__x509__raw_x509_signature__test__sig_to_raw.snap new file mode 100644 index 00000000000..133c5cca198 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/snapshots/matrix_sdk_crypto__x509__raw_x509_signature__test__sig_to_raw.snap @@ -0,0 +1,1377 @@ +--- +source: crates/matrix-sdk-crypto/src/x509/raw_x509_signature.rs +expression: raw_signature +--- +RawX509SignatureAndFirstCertificate { + raw_x509signature: RawX509Signature { + signature_bytes: [ + 85, + 173, + 87, + 96, + 106, + 251, + 134, + 36, + 222, + 34, + 89, + 197, + 67, + 78, + 188, + 186, + 26, + 32, + 58, + 26, + 231, + 168, + 232, + 49, + 107, + 86, + 255, + 211, + 144, + 163, + 183, + 42, + 58, + 186, + 250, + 42, + 115, + 206, + 42, + 13, + 177, + 74, + 16, + 73, + 137, + 7, + 32, + 253, + 202, + 211, + 146, + 217, + 133, + 237, + 31, + 52, + 28, + 228, + 223, + 206, + 76, + 49, + 158, + 199, + 126, + 241, + 189, + 184, + 210, + 223, + 163, + 183, + 197, + 201, + 152, + 249, + 151, + 239, + 4, + 95, + 96, + 209, + 174, + 176, + 41, + 243, + 20, + 73, + 197, + 160, + 111, + 78, + 219, + 111, + 42, + 211, + 14, + 217, + 35, + 171, + 209, + 132, + 52, + 98, + 122, + 98, + 150, + 102, + 125, + 210, + 72, + 138, + 42, + 80, + 79, + 232, + 201, + 187, + 244, + 51, + 174, + 137, + 249, + 56, + 180, + 165, + 51, + 114, + 34, + 237, + 33, + 92, + 42, + 47, + 42, + 88, + 221, + 218, + 189, + 92, + 16, + 160, + 130, + 217, + 55, + 218, + 80, + 117, + 41, + 23, + 86, + 221, + 88, + 57, + 249, + 68, + 108, + 60, + 15, + 206, + 221, + 172, + 63, + 181, + 96, + 136, + 198, + 201, + 44, + 85, + 69, + 253, + 230, + 57, + 254, + 48, + 250, + 242, + 0, + 249, + 48, + 116, + 70, + 29, + 235, + 131, + 20, + 3, + 43, + 2, + 15, + 153, + 126, + 183, + 185, + 160, + 203, + 25, + 56, + 185, + 47, + 38, + 46, + 209, + 63, + 36, + 44, + 44, + 6, + 245, + 122, + 63, + 49, + 54, + 20, + 76, + 16, + 97, + 254, + 186, + 222, + 105, + 118, + 138, + 80, + 94, + 168, + 116, + 68, + 78, + 30, + 231, + 129, + 92, + 26, + 213, + 240, + 80, + 249, + 148, + 41, + 207, + 96, + 78, + 182, + 24, + 231, + 149, + 255, + 88, + 88, + 112, + 138, + 227, + 14, + 231, + ], + certificate_chain: "-----BEGIN CERTIFICATE-----\r\nMIIEMDCCAhigAwIBAgIBATANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQGEwJVSzEP\r\nMA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRgwFgYDVQQDDA9p\r\nbnRlcm1lZGlhdGUtY2EwHhcNMjYwNjExMjI1MjMyWhcNMjcwNjExMjI1MjMyWjAm\r\nMSQwIgYJKoZIhvcNAQkBFhV2ZGgteDUwOXRlc3RAc3cxdi5vcmcwggEiMA0GCSqG\r\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQW4+eDT5UlecWnOeLjzMIcQ/sdRb20uPF\r\niiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+4HCVCYHoKEc9H/Zm2W2N\r\n63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhqrq14scYCQiFVstdtq5Pc\r\n+F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOsNqX91Tej2tsAZvRbkBms\r\nILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpeiz7DESW5VGFUHwg1FdWAG\r\ntTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8C1IoSTtVAgMBAAGjQjBA\r\nMB0GA1UdDgQWBBSMiQQcHg6Ixffg8lknAK1wY42fjDAfBgNVHSMEGDAWgBR0DWpq\r\nYxJI0M81uFSlqoYvckCA3jANBgkqhkiG9w0BAQsFAAOCAgEArMHX8k+xYTUVAOSR\r\nVzt8aMMC/LSwN0zQq/+CLX3AuhE94j+2cRM3TF5jpzeRmTObpaK88qVN5CrrK5IC\r\nddMvMAW+eAUyJc7aXX1tPWD0YRcWlplR0CVm6RY/VIwsVlWvhIX6fYpAJBMoYqr4\r\ntCYcDjA1/FNkuUQnBNnxnp0I3HQuSa1UBCCHZ/00kbVl331XY5bxZrk1OSUWP9TF\r\npHdW1Fgjqu0EZccjEYK7F1I+weTfkl/uOp6QUl7cmhiLnMsd0QwKUdFStOy1BYX8\r\ntbkMN44diYC9hRUhP9gsud12dBeF4EKl66y43zgUNPAzD3MMmU/xndWIgFs+/PnK\r\nvlbtSMl7wdTr0bh+c3srvY4uuft2Uy0PH9mi0Sffy6LKqg5FUT6kdPtQscZYamb0\r\nTfJ4xjFPII7H6mkvnH/Nm0RWTvsChLeyrPQ8kl328NW0NWlitBZ0k3jiQj0Aqfim\r\nuZwS3f03oqA/254nZU9K6SC5Nvq0L7g71XGK6ZwMVOK3Vw3TV6XLq+nO3FilSrk3\r\na48QHPLwSRAs7dMIWztnycB/hyMp7daocSieza74AxSwZv6KS6ilEBFv3z9OjTS0\r\nZ5Zy87N5SmP89OfE92IxNQ+tYlnk2THqZSBbSj5hqdHOUVWCzbvHxFEKGSzK3+B5\r\nvDQS6Fr6qeVx8k6TzrvYTf9u61w=\r\n-----END CERTIFICATE-----\r\n-----BEGIN CERTIFICATE-----\r\nMIIFcDCCA1igAwIBAgIBCDANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJVSzEP\r\nMA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRAwDgYDVQQDDAd0\r\nZXN0LWNhMB4XDTI2MDYwNTE1MDIxNloXDTI3MDYwNTE1MDIxNlowTTELMAkGA1UE\r\nBhMCVUsxDzANBgNVBAgMBkxvbmRvbjETMBEGA1UECgwKZWxlbWVudC5pbzEYMBYG\r\nA1UEAwwPaW50ZXJtZWRpYXRlLWNhMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC\r\nCgKCAgEAt+6qbrTcH+TiHxJl7pJjIajUXvu0nYwnfqDQcaZRY0qlqPuHccBudNfk\r\nFMoD6fchj5OvyQsZLP8UnirLxVgf6GgTLZ2JK4tTpaCFWv45ukHVYeY/r+c0fOuy\r\nzUw4H5LljJ4ikPI3w7vncSQTOCYHpDGVhW8Mh4mAug7ODlL3RZVSsRxuikDhLQoR\r\nOS2pRDwTFll1CYE8+MTkxPcGrnOFQMqB9290af0PnkS64BO0nPUZYqsPELkcPkGU\r\nlc0mTWYunvfG21cU7TnPGRto4nj5l4xZBGpRZSEf3L3yh+4Chi35Rq1hWHlQ8ito\r\nX7n1vNELkHShkQcSmALEmlspGPt6Mhjdb3Xj1N3wNlftXe7g2tFEcnpXu6EwNJYP\r\nmljN1qEA/37xBz9JyZzeahPvOg/hF/+EX3/FcnXLDAYCbFnx/44Ljnr7MHqjQwfH\r\nB7Ndcp22OHC6fwAEjuTPTQlbMmxrxXETKsQwg7aVIPs743R6RJeBQbkUP3hm/7G0\r\nuwoB2kphdjvGHYJtDguNXHuiXz/Pn6F+D6m3j/SDF4uAfnzxKDDVURPlQg2Wr2od\r\nez0aklHgJ9ZUAfpk3C6f1VGUmxpjVhp6nPsQ5ZgGFHNOjWZuV2+cwxahpYG6KzVD\r\nmO/brmSc76ibzRwapIOLSjb+plbp8VGrBElIhvqRFyZmTOcGdLkCAwEAAaNjMGEw\r\nHQYDVR0OBBYEFHQNampjEkjQzzW4VKWqhi9yQIDeMB8GA1UdIwQYMBaAFNhekZoX\r\n8MNbE9t1Qn0hN5rfPpYRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG\r\nMA0GCSqGSIb3DQEBCwUAA4ICAQA1X27xupoF0brABykMbCE4Q1QQ2u3oLUOoX0vP\r\nEVBjGC7tlIe6O1+s8lCSSM6pAly61kMl2rIXE7GzPMLVNjg67etXfNbb8KXPqiKi\r\nbPPLQ6Pk5/RJd3D8DUO4gKgEKtkBZK30JzKVsl5eLgxT1GVnDXgV9jNLqXuNgreZ\r\nBa4BYjdtR2pMbVlW0ZEda7m4N5CDh8rfJQqnZeudhzqjecXV5bxxvKotvOEgsYml\r\nWZ4yxAwuJhlb+I/Rmc+hocsRBx8v8LIIQh/biUMaZnoCqj8d9qkpFmf9nWhXQRbJ\r\nSqz2ad08ZXxMY+vXHQ3mR1H4cYm5jOWuyXWSULo/WT+OrGPFrH1/E3IjhvVaal6m\r\nc4IJQZCI7iHhyeHLxjaVmctgwV2yMUZnCuBXrv0yHkkDyr1D6OBaHF/0dYSd/rym\r\nZDo1S4QcKmoJJRP0nDdq7zMoLneu7Ytmn3PZ8qiAUnh5vlaQCg3SHkdQ0berp7yU\r\n+0tFAPc7c4+yad+YBxLi+Zqplqw9Ra810Nn+coBH54+DOytWQQdK4kx0pXc+2MYR\r\nsykQE11ItydeFcAu2Rtp6dw5G9DNOIQJWYeGOECx6phCZYrZVEPZoeF0dnsEcdP8\r\nF5aVeO+O1gffTCxzhLTqMXNvm86oP6QC71gC1w6eM4uuGubc56Dm4hxVywwRUGgJ\r\ntsmVjQ==\r\n-----END CERTIFICATE-----\r\n", + signature_scheme: RsaPssSha512, + }, + leaf_cert: CertificateInner { + tbs_certificate: TbsCertificateInner { + version: V3, + serial_number: SerialNumber { + inner: Int { + inner: BytesOwned { + length: Length( + 1, + ), + inner: [ + 1, + ], + }, + }, + _profile: PhantomData, + }, + signature: AlgorithmIdentifier { + oid: ObjectIdentifier(1.2.840.113549.1.1.11), + parameters: Some( + Any { + tag: Tag(0x05: NULL), + value: BytesOwned { + length: Length( + 0, + ), + inner: [], + }, + }, + ), + }, + issuer: RdnSequence( + [ + RelativeDistinguishedName( + SetOfVec { + inner: [ + AttributeTypeAndValue { + oid: ObjectIdentifier(2.5.4.6), + value: Any { + tag: Tag(0x13: PrintableString), + value: BytesOwned { + length: Length( + 2, + ), + inner: [ + 85, + 75, + ], + }, + }, + }, + ], + }, + ), + RelativeDistinguishedName( + SetOfVec { + inner: [ + AttributeTypeAndValue { + oid: ObjectIdentifier(2.5.4.8), + value: Any { + tag: Tag(0x0c: UTF8String), + value: BytesOwned { + length: Length( + 6, + ), + inner: [ + 76, + 111, + 110, + 100, + 111, + 110, + ], + }, + }, + }, + ], + }, + ), + RelativeDistinguishedName( + SetOfVec { + inner: [ + AttributeTypeAndValue { + oid: ObjectIdentifier(2.5.4.10), + value: Any { + tag: Tag(0x0c: UTF8String), + value: BytesOwned { + length: Length( + 10, + ), + inner: [ + 101, + 108, + 101, + 109, + 101, + 110, + 116, + 46, + 105, + 111, + ], + }, + }, + }, + ], + }, + ), + RelativeDistinguishedName( + SetOfVec { + inner: [ + AttributeTypeAndValue { + oid: ObjectIdentifier(2.5.4.3), + value: Any { + tag: Tag(0x0c: UTF8String), + value: BytesOwned { + length: Length( + 15, + ), + inner: [ + 105, + 110, + 116, + 101, + 114, + 109, + 101, + 100, + 105, + 97, + 116, + 101, + 45, + 99, + 97, + ], + }, + }, + }, + ], + }, + ), + ], + ), + validity: Validity { + not_before: UtcTime( + UtcTime( + DateTime { + year: 2026, + month: 6, + day: 11, + hour: 22, + minutes: 52, + seconds: 32, + unix_duration: 1781218352s, + }, + ), + ), + not_after: UtcTime( + UtcTime( + DateTime { + year: 2027, + month: 6, + day: 11, + hour: 22, + minutes: 52, + seconds: 32, + unix_duration: 1812754352s, + }, + ), + ), + }, + subject: RdnSequence( + [ + RelativeDistinguishedName( + SetOfVec { + inner: [ + AttributeTypeAndValue { + oid: ObjectIdentifier(1.2.840.113549.1.9.1), + value: Any { + tag: Tag(0x16: IA5String), + value: BytesOwned { + length: Length( + 21, + ), + inner: [ + 118, + 100, + 104, + 45, + 120, + 53, + 48, + 57, + 116, + 101, + 115, + 116, + 64, + 115, + 119, + 49, + 118, + 46, + 111, + 114, + 103, + ], + }, + }, + }, + ], + }, + ), + ], + ), + subject_public_key_info: SubjectPublicKeyInfo { + algorithm: AlgorithmIdentifier { + oid: ObjectIdentifier(1.2.840.113549.1.1.1), + parameters: Some( + Any { + tag: Tag(0x05: NULL), + value: BytesOwned { + length: Length( + 0, + ), + inner: [], + }, + }, + ), + }, + subject_public_key: BitString { + unused_bits: 0, + bit_length: 2160, + inner: [ + 48, + 130, + 1, + 10, + 2, + 130, + 1, + 1, + 0, + 144, + 91, + 143, + 158, + 13, + 62, + 84, + 149, + 231, + 22, + 156, + 231, + 139, + 143, + 51, + 8, + 113, + 15, + 236, + 117, + 22, + 246, + 210, + 227, + 197, + 138, + 36, + 77, + 155, + 52, + 187, + 146, + 245, + 136, + 198, + 196, + 203, + 139, + 107, + 124, + 166, + 104, + 80, + 179, + 26, + 136, + 220, + 39, + 72, + 91, + 37, + 159, + 16, + 124, + 185, + 232, + 218, + 126, + 224, + 112, + 149, + 9, + 129, + 232, + 40, + 71, + 61, + 31, + 246, + 102, + 217, + 109, + 141, + 235, + 118, + 5, + 77, + 154, + 37, + 237, + 82, + 149, + 97, + 16, + 7, + 60, + 119, + 77, + 135, + 78, + 178, + 235, + 6, + 245, + 46, + 236, + 211, + 118, + 206, + 110, + 74, + 145, + 44, + 189, + 24, + 106, + 174, + 173, + 120, + 177, + 198, + 2, + 66, + 33, + 85, + 178, + 215, + 109, + 171, + 147, + 220, + 248, + 94, + 67, + 164, + 185, + 198, + 166, + 141, + 70, + 191, + 50, + 98, + 23, + 84, + 249, + 90, + 147, + 96, + 147, + 209, + 42, + 112, + 255, + 88, + 178, + 142, + 43, + 167, + 12, + 239, + 213, + 51, + 172, + 54, + 165, + 253, + 213, + 55, + 163, + 218, + 219, + 0, + 102, + 244, + 91, + 144, + 25, + 172, + 32, + 188, + 168, + 20, + 199, + 183, + 20, + 28, + 25, + 96, + 108, + 183, + 157, + 222, + 122, + 51, + 63, + 203, + 131, + 18, + 120, + 21, + 145, + 27, + 204, + 119, + 135, + 67, + 104, + 183, + 142, + 151, + 162, + 207, + 176, + 196, + 73, + 110, + 85, + 24, + 85, + 7, + 194, + 13, + 69, + 117, + 96, + 6, + 181, + 60, + 196, + 79, + 231, + 168, + 84, + 52, + 66, + 245, + 140, + 209, + 71, + 226, + 154, + 243, + 36, + 43, + 187, + 73, + 5, + 104, + 176, + 33, + 1, + 72, + 239, + 202, + 131, + 40, + 212, + 214, + 252, + 11, + 82, + 40, + 73, + 59, + 85, + 2, + 3, + 1, + 0, + 1, + ], + }, + }, + issuer_unique_id: None, + subject_unique_id: None, + extensions: Some( + [ + Extension { + extn_id: ObjectIdentifier(2.5.29.14), + critical: false, + extn_value: OctetString { + inner: [ + 4, + 20, + 140, + 137, + 4, + 28, + 30, + 14, + 136, + 197, + 247, + 224, + 242, + 89, + 39, + 0, + 173, + 112, + 99, + 141, + 159, + 140, + ], + }, + }, + Extension { + extn_id: ObjectIdentifier(2.5.29.35), + critical: false, + extn_value: OctetString { + inner: [ + 48, + 22, + 128, + 20, + 116, + 13, + 106, + 106, + 99, + 18, + 72, + 208, + 207, + 53, + 184, + 84, + 165, + 170, + 134, + 47, + 114, + 64, + 128, + 222, + ], + }, + }, + ], + ), + }, + signature_algorithm: AlgorithmIdentifier { + oid: ObjectIdentifier(1.2.840.113549.1.1.11), + parameters: Some( + Any { + tag: Tag(0x05: NULL), + value: BytesOwned { + length: Length( + 0, + ), + inner: [], + }, + }, + ), + }, + signature: BitString { + unused_bits: 0, + bit_length: 4096, + inner: [ + 172, + 193, + 215, + 242, + 79, + 177, + 97, + 53, + 21, + 0, + 228, + 145, + 87, + 59, + 124, + 104, + 195, + 2, + 252, + 180, + 176, + 55, + 76, + 208, + 171, + 255, + 130, + 45, + 125, + 192, + 186, + 17, + 61, + 226, + 63, + 182, + 113, + 19, + 55, + 76, + 94, + 99, + 167, + 55, + 145, + 153, + 51, + 155, + 165, + 162, + 188, + 242, + 165, + 77, + 228, + 42, + 235, + 43, + 146, + 2, + 117, + 211, + 47, + 48, + 5, + 190, + 120, + 5, + 50, + 37, + 206, + 218, + 93, + 125, + 109, + 61, + 96, + 244, + 97, + 23, + 22, + 150, + 153, + 81, + 208, + 37, + 102, + 233, + 22, + 63, + 84, + 140, + 44, + 86, + 85, + 175, + 132, + 133, + 250, + 125, + 138, + 64, + 36, + 19, + 40, + 98, + 170, + 248, + 180, + 38, + 28, + 14, + 48, + 53, + 252, + 83, + 100, + 185, + 68, + 39, + 4, + 217, + 241, + 158, + 157, + 8, + 220, + 116, + 46, + 73, + 173, + 84, + 4, + 32, + 135, + 103, + 253, + 52, + 145, + 181, + 101, + 223, + 125, + 87, + 99, + 150, + 241, + 102, + 185, + 53, + 57, + 37, + 22, + 63, + 212, + 197, + 164, + 119, + 86, + 212, + 88, + 35, + 170, + 237, + 4, + 101, + 199, + 35, + 17, + 130, + 187, + 23, + 82, + 62, + 193, + 228, + 223, + 146, + 95, + 238, + 58, + 158, + 144, + 82, + 94, + 220, + 154, + 24, + 139, + 156, + 203, + 29, + 209, + 12, + 10, + 81, + 209, + 82, + 180, + 236, + 181, + 5, + 133, + 252, + 181, + 185, + 12, + 55, + 142, + 29, + 137, + 128, + 189, + 133, + 21, + 33, + 63, + 216, + 44, + 185, + 221, + 118, + 116, + 23, + 133, + 224, + 66, + 165, + 235, + 172, + 184, + 223, + 56, + 20, + 52, + 240, + 51, + 15, + 115, + 12, + 153, + 79, + 241, + 157, + 213, + 136, + 128, + 91, + 62, + 252, + 249, + 202, + 190, + 86, + 237, + 72, + 201, + 123, + 193, + 212, + 235, + 209, + 184, + 126, + 115, + 123, + 43, + 189, + 142, + 46, + 185, + 251, + 118, + 83, + 45, + 15, + 31, + 217, + 162, + 209, + 39, + 223, + 203, + 162, + 202, + 170, + 14, + 69, + 81, + 62, + 164, + 116, + 251, + 80, + 177, + 198, + 88, + 106, + 102, + 244, + 77, + 242, + 120, + 198, + 49, + 79, + 32, + 142, + 199, + 234, + 105, + 47, + 156, + 127, + 205, + 155, + 68, + 86, + 78, + 251, + 2, + 132, + 183, + 178, + 172, + 244, + 60, + 146, + 93, + 246, + 240, + 213, + 180, + 53, + 105, + 98, + 180, + 22, + 116, + 147, + 120, + 226, + 66, + 61, + 0, + 169, + 248, + 166, + 185, + 156, + 18, + 221, + 253, + 55, + 162, + 160, + 63, + 219, + 158, + 39, + 101, + 79, + 74, + 233, + 32, + 185, + 54, + 250, + 180, + 47, + 184, + 59, + 213, + 113, + 138, + 233, + 156, + 12, + 84, + 226, + 183, + 87, + 13, + 211, + 87, + 165, + 203, + 171, + 233, + 206, + 220, + 88, + 165, + 74, + 185, + 55, + 107, + 143, + 16, + 28, + 242, + 240, + 73, + 16, + 44, + 237, + 211, + 8, + 91, + 59, + 103, + 201, + 192, + 127, + 135, + 35, + 41, + 237, + 214, + 168, + 113, + 40, + 158, + 205, + 174, + 248, + 3, + 20, + 176, + 102, + 254, + 138, + 75, + 168, + 165, + 16, + 17, + 111, + 223, + 63, + 78, + 141, + 52, + 180, + 103, + 150, + 114, + 243, + 179, + 121, + 74, + 99, + 252, + 244, + 231, + 196, + 247, + 98, + 49, + 53, + 15, + 173, + 98, + 89, + 228, + 217, + 49, + 234, + 101, + 32, + 91, + 74, + 62, + 97, + 169, + 209, + 206, + 81, + 85, + 130, + 205, + 187, + 199, + 196, + 81, + 10, + 25, + 44, + 202, + 223, + 224, + 121, + 188, + 52, + 18, + 232, + 90, + 250, + 169, + 229, + 113, + 242, + 78, + 147, + 206, + 187, + 216, + 77, + 255, + 110, + 235, + 92, + ], + }, + }, +} diff --git a/crates/matrix-sdk-crypto/src/x509/test_cms.pem b/crates/matrix-sdk-crypto/src/x509/test_cms.pem new file mode 100644 index 00000000000..99ad59e6876 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/test_cms.pem @@ -0,0 +1,63 @@ +-----BEGIN CMS----- +MIILVwYJKoZIhvcNAQcCoIILSDCCC0QCAQMxDzANBglghkgBZQMEAgMFADALBgkq +hkiG9w0BBwGgggmoMIIEMDCCAhigAwIBAgIBATANBgkqhkiG9w0BAQsFADBNMQsw +CQYDVQQGEwJVSzEPMA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50Lmlv +MRgwFgYDVQQDDA9pbnRlcm1lZGlhdGUtY2EwHhcNMjYwNjExMjI1MjMyWhcNMjcw +NjExMjI1MjMyWjAmMSQwIgYJKoZIhvcNAQkBFhV2ZGgteDUwOXRlc3RAc3cxdi5v +cmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQW4+eDT5UlecWnOeL +jzMIcQ/sdRb20uPFiiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+4HCV +CYHoKEc9H/Zm2W2N63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhqrq14 +scYCQiFVstdtq5Pc+F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOsNqX9 +1Tej2tsAZvRbkBmsILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpeiz7DE +SW5VGFUHwg1FdWAGtTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8C1Io +STtVAgMBAAGjQjBAMB0GA1UdDgQWBBSMiQQcHg6Ixffg8lknAK1wY42fjDAfBgNV +HSMEGDAWgBR0DWpqYxJI0M81uFSlqoYvckCA3jANBgkqhkiG9w0BAQsFAAOCAgEA +rMHX8k+xYTUVAOSRVzt8aMMC/LSwN0zQq/+CLX3AuhE94j+2cRM3TF5jpzeRmTOb +paK88qVN5CrrK5ICddMvMAW+eAUyJc7aXX1tPWD0YRcWlplR0CVm6RY/VIwsVlWv +hIX6fYpAJBMoYqr4tCYcDjA1/FNkuUQnBNnxnp0I3HQuSa1UBCCHZ/00kbVl331X +Y5bxZrk1OSUWP9TFpHdW1Fgjqu0EZccjEYK7F1I+weTfkl/uOp6QUl7cmhiLnMsd +0QwKUdFStOy1BYX8tbkMN44diYC9hRUhP9gsud12dBeF4EKl66y43zgUNPAzD3MM +mU/xndWIgFs+/PnKvlbtSMl7wdTr0bh+c3srvY4uuft2Uy0PH9mi0Sffy6LKqg5F +UT6kdPtQscZYamb0TfJ4xjFPII7H6mkvnH/Nm0RWTvsChLeyrPQ8kl328NW0NWli +tBZ0k3jiQj0AqfimuZwS3f03oqA/254nZU9K6SC5Nvq0L7g71XGK6ZwMVOK3Vw3T +V6XLq+nO3FilSrk3a48QHPLwSRAs7dMIWztnycB/hyMp7daocSieza74AxSwZv6K +S6ilEBFv3z9OjTS0Z5Zy87N5SmP89OfE92IxNQ+tYlnk2THqZSBbSj5hqdHOUVWC +zbvHxFEKGSzK3+B5vDQS6Fr6qeVx8k6TzrvYTf9u61wwggVwMIIDWKADAgECAgEI +MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAlVLMQ8wDQYDVQQIDAZMb25kb24x +EzARBgNVBAoMCmVsZW1lbnQuaW8xEDAOBgNVBAMMB3Rlc3QtY2EwHhcNMjYwNjA1 +MTUwMjE2WhcNMjcwNjA1MTUwMjE2WjBNMQswCQYDVQQGEwJVSzEPMA0GA1UECAwG +TG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRgwFgYDVQQDDA9pbnRlcm1lZGlh +dGUtY2EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC37qputNwf5OIf +EmXukmMhqNRe+7SdjCd+oNBxplFjSqWo+4dxwG501+QUygPp9yGPk6/JCxks/xSe +KsvFWB/oaBMtnYkri1OloIVa/jm6QdVh5j+v5zR867LNTDgfkuWMniKQ8jfDu+dx +JBM4JgekMZWFbwyHiYC6Ds4OUvdFlVKxHG6KQOEtChE5LalEPBMWWXUJgTz4xOTE +9wauc4VAyoH3b3Rp/Q+eRLrgE7Sc9Rliqw8QuRw+QZSVzSZNZi6e98bbVxTtOc8Z +G2jiePmXjFkEalFlIR/cvfKH7gKGLflGrWFYeVDyK2hfufW80QuQdKGRBxKYAsSa +WykY+3oyGN1vdePU3fA2V+1d7uDa0URyele7oTA0lg+aWM3WoQD/fvEHP0nJnN5q +E+86D+EX/4Rff8VydcsMBgJsWfH/jguOevsweqNDB8cHs11ynbY4cLp/AASO5M9N +CVsybGvFcRMqxDCDtpUg+zvjdHpEl4FBuRQ/eGb/sbS7CgHaSmF2O8Ydgm0OC41c +e6JfP8+foX4PqbeP9IMXi4B+fPEoMNVRE+VCDZavah17PRqSUeAn1lQB+mTcLp/V +UZSbGmNWGnqc+xDlmAYUc06NZm5Xb5zDFqGlgborNUOY79uuZJzvqJvNHBqkg4tK +Nv6mVunxUasESUiG+pEXJmZM5wZ0uQIDAQABo2MwYTAdBgNVHQ4EFgQUdA1qamMS +SNDPNbhUpaqGL3JAgN4wHwYDVR0jBBgwFoAU2F6Rmhfww1sT23VCfSE3mt8+lhEw +DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggIBADVfbvG6mgXRusAHKQxsIThDVBDa7egtQ6hfS88RUGMYLu2Uh7o7X6zyUJJI +zqkCXLrWQyXashcTsbM8wtU2ODrt61d81tvwpc+qIqJs88tDo+Tn9El3cPwNQ7iA +qAQq2QFkrfQnMpWyXl4uDFPUZWcNeBX2M0upe42Ct5kFrgFiN21HakxtWVbRkR1r +ubg3kIOHyt8lCqdl652HOqN5xdXlvHG8qi284SCxiaVZnjLEDC4mGVv4j9GZz6Gh +yxEHHy/wsghCH9uJQxpmegKqPx32qSkWZ/2daFdBFslKrPZp3TxlfExj69cdDeZH +UfhxibmM5a7JdZJQuj9ZP46sY8WsfX8TciOG9VpqXqZzgglBkIjuIeHJ4cvGNpWZ +y2DBXbIxRmcK4Feu/TIeSQPKvUPo4FocX/R1hJ3+vKZkOjVLhBwqagklE/ScN2rv +Mygud67ti2afc9nyqIBSeHm+VpAKDdIeR1DRt6unvJT7S0UA9ztzj7Jp35gHEuL5 +mqmWrD1FrzXQ2f5ygEfnj4M7K1ZBB0riTHSldz7YxhGzKRATXUi3J14VwC7ZG2np +3Dkb0M04hAlZh4Y4QLHqmEJlitlUQ9mh4XR2ewRx0/wXlpV4747WB99MLHOEtOox +c2+bzqg/pALvWALXDp4zi64a5tznoObiHFXLDBFQaAm2yZWNMYIBczCCAW8CAQOA +FIyJBBweDojF9+DyWScArXBjjZ+MMA0GCWCGSAFlAwQCAwUAMEEGCSqGSIb3DQEB +CjA0oA8wDQYJYIZIAWUDBAIDBQChHDAaBgkqhkiG9w0BAQgwDQYJYIZIAWUDBAID +BQCiAwIBQASCAQBVrVdgavuGJN4iWcVDTry6GiA6Gueo6DFrVv/TkKO3Kjq6+ipz +zioNsUoQSYkHIP3K05LZhe0fNBzk385MMZ7HfvG9uNLfo7fFyZj5l+8EX2DRrrAp +8xRJxaBvTttvKtMO2SOr0YQ0YnpilmZ90kiKKlBP6Mm79DOuifk4tKUzciLtIVwq +LypY3dq9XBCggtk32lB1KRdW3Vg5+URsPA/O3aw/tWCIxsksVUX95jn+MPryAPkw +dEYd64MUAysCD5l+t7mgyxk4uS8mLtE/JCwsBvV6PzE2FEwQYf663ml2ilBeqHRE +Th7ngVwa1fBQ+ZQpz2BOthjnlf9YWHCK4w7n +-----END CMS----- From fd633988cd892f0ed97e6a5996e7e7456af9ebd6 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 12:51:12 +0100 Subject: [PATCH 04/13] feat(crypto): Add pure-Rust X.509 signer and verifier Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- Cargo.lock | 171 +++++++++++++++++- crates/matrix-sdk-crypto/Cargo.toml | 15 ++ crates/matrix-sdk-crypto/src/x509/mod.rs | 9 + .../src/x509/rust_raw_x509_signer.rs | 109 +++++++++++ .../src/x509/rust_raw_x509_verifier.rs | 134 ++++++++++++++ 5 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs create mode 100644 crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs diff --git a/Cargo.lock b/Cargo.lock index 8c4c3f71ecb..273e8b33d06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,45 @@ dependencies = [ "winnow 0.7.13", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -341,6 +380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -509,6 +549,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1320,6 +1369,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "datasketches" version = "0.2.0" @@ -1428,6 +1483,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der_derive" version = "0.7.3" @@ -1436,7 +1505,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.101", + "syn 2.0.118", ] [[package]] @@ -3566,9 +3635,12 @@ dependencies = [ "pbkdf2", "proptest", "rand 0.10.1", + "rcgen", "rmp-serde", "rsa", "ruma", + "rustls", + "rustls-webpki", "serde", "serde_json", "sha2", @@ -4152,6 +4224,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4272,6 +4354,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4404,6 +4495,16 @@ dependencies = [ "digest", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5074,6 +5175,21 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "readlock" version = "0.1.8" @@ -5190,7 +5306,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.15", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -5470,6 +5586,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.0.8" @@ -5485,11 +5610,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -5555,7 +5681,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -7123,6 +7249,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -8237,6 +8369,25 @@ dependencies = [ "spki", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xshell" version = "0.2.2" @@ -8276,6 +8427,16 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63658493314859b4dfdf3fb8c1defd61587839def09582db50b8a4e93afca6bb" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/crates/matrix-sdk-crypto/Cargo.toml b/crates/matrix-sdk-crypto/Cargo.toml index abb76f76a06..0b91a411a63 100644 --- a/crates/matrix-sdk-crypto/Cargo.toml +++ b/crates/matrix-sdk-crypto/Cargo.toml @@ -14,6 +14,11 @@ version = "0.18.0" [package.metadata.docs.rs] rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] +# `cargo-machete` doesn't consider lib renames unless `--with-metadata` +# is passed, but this errors CI further. +[package.metadata.cargo-machete] +ignored = ["rustls-webpki"] + [features] default = [] automatic-room-key-forwarding = [] @@ -47,6 +52,9 @@ experimental-push-secrets = [] # Enable experirmental support for verifying user identities via X.509 certificates experimental-x509-identity-verification = ["dep:cms", "dep:rsa", "sha2/oid"] +# Enable a pure-rust implementation of the X.509 signer and verifier, via `rustls`. +rust-x509-verifier-impl = ["experimental-x509-identity-verification", "dep:rustls", "dep:rustls-webpki"] + [dependencies] aes = { version = "0.8.4", default-features = false } aquamarine.workspace = true @@ -72,6 +80,8 @@ rand.workspace = true rmp-serde.workspace = true rsa = { version = "0.9.10", default-features = false, optional = true } ruma = { workspace = true, features = ["rand", "unstable-msc3814"] } +rustls = { version = "0.23.40", optional = true } +rustls-webpki = { version = "0.103.13", default-features = false, optional = true } serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true sha2.workspace = true @@ -104,10 +114,15 @@ matrix-sdk-test.workspace = true matrix-sdk-test-utils.workspace = true proptest.workspace = true rmp-serde.workspace = true +rcgen = { version = "0.14.8", features = ["aws_lc_rs", "x509-parser"] } similar-asserts.workspace = true # required for async_test macro stream_assert.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# The x509 tests use rust-x509-verifier-impl, so we need its dependencies +rustls = { version = "0.23.40" } +rustls-webpki = { version = "0.103.13", default-features = false } + [lints] workspace = true diff --git a/crates/matrix-sdk-crypto/src/x509/mod.rs b/crates/matrix-sdk-crypto/src/x509/mod.rs index bb408d94953..8fa0b65d1d1 100644 --- a/crates/matrix-sdk-crypto/src/x509/mod.rs +++ b/crates/matrix-sdk-crypto/src/x509/mod.rs @@ -25,3 +25,12 @@ mod x509_verify; pub use raw_x509_signature::RawX509Signature; pub use x509_signer::{RawX509Signer, X509Signer}; pub use x509_verify::{RawX509Verifier, X509Verifier}; + +#[cfg(any(test, feature = "rust-x509-verifier-impl"))] +mod rust_raw_x509_signer; +#[cfg(any(test, feature = "rust-x509-verifier-impl"))] +mod rust_raw_x509_verifier; +#[cfg(any(test, feature = "rust-x509-verifier-impl"))] +pub use rust_raw_x509_signer::RustRawX509Signer; +#[cfg(any(test, feature = "rust-x509-verifier-impl"))] +pub use rust_raw_x509_verifier::RustRawX509Verifier; diff --git a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs new file mode 100644 index 00000000000..77d36e76d48 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs @@ -0,0 +1,109 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::sync::Arc; + +use rustls::{ + SignatureScheme, + crypto::aws_lc_rs, + pki_types::{PrivateKeyDer, pem::PemObject}, + sign::SigningKey, +}; +use thiserror::Error; + +use crate::{ + SignatureError, + x509::{ + raw_x509_signature::{RawX509Signature, X509SignatureScheme}, + x509_signer::RawX509Signer, + }, +}; + +/// A Rust implementation of [`RawX509Signer`]. This does the signing +/// itself (using `rustls`) rather than delegating the work to some external +/// system. +#[derive(Clone)] +pub struct RustRawX509Signer { + /// The PEM-encoded certificate chain, starting with the device's own + /// certificate, followed by intermediate certificates. + certificate_chain: String, + + /// The private signing key for this device. + signing_key: Arc, +} + +#[derive(Error, Debug)] +pub enum RustX509SignError { + /// There was an error parsing the certificate chain. + #[error("failed to parse certificate chain {0}")] + CertificateParse(rustls::pki_types::pem::Error), + + /// No certificates were found. + #[error("no certificates found in chain")] + CertificateNotFound, + + /// There was an error parsing the private key. + #[error("failed to parse private key {0}")] + PrivateKeyParse(rustls::pki_types::pem::Error), + + /// There was an error loading the private key. + #[error("failed to load private key {0}")] + PrivateKeyLoad(rustls::Error), +} + +impl RustRawX509Signer { + /// Create a new `RustRawX509Signer` from the supplied PEM data. + pub fn new_from_pem_data( + certificate_chain_pem: &str, + private_key_pem: &str, + ) -> Result { + let provider = aws_lc_rs::default_provider(); + + let private_key = PrivateKeyDer::from_pem_slice(private_key_pem.as_bytes()) + .map_err(RustX509SignError::PrivateKeyParse)?; + let signing_key: Arc = provider + .key_provider + .load_private_key(private_key) + .map_err(RustX509SignError::PrivateKeyLoad)?; + + Ok(Self { certificate_chain: certificate_chain_pem.to_owned(), signing_key }) + } +} + +impl RawX509Signer for RustRawX509Signer { + /// Create a signature for the given message using our private key + /// + /// Returns (key ID, signature) + fn sign(&self, message: &[u8]) -> Result { + let signer = self + .signing_key + .choose_scheme(&[SignatureScheme::RSA_PSS_SHA512]) + .ok_or(SignatureError::UnsupportedAlgorithm)?; + + let signature_bytes = + signer.sign(message).map_err(|e| SignatureError::X509SigningError(e.to_string()))?; + + Ok(RawX509Signature { + signature_bytes, + certificate_chain: self.certificate_chain.clone(), + signature_scheme: X509SignatureScheme::RsaPssSha512, + }) + } +} + +impl std::fmt::Debug for RustRawX509Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("X509Keys").field(&"".to_owned()).finish() + } +} diff --git a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs new file mode 100644 index 00000000000..a60e500d361 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs @@ -0,0 +1,134 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License + +use std::sync::Arc; + +use rustls::{ + RootCertStore, SignatureScheme, + crypto::CryptoProvider, + pki_types::{CertificateDer, UnixTime, pem::PemObject}, + server::{VerifierBuilderError, WebPkiClientVerifier, danger::ClientCertVerifier}, +}; +use thiserror::Error; +use webpki::EndEntityCert; + +use crate::x509::{ + raw_x509_signature::{RawX509Signature, X509SignatureScheme}, + x509_verify::RawX509Verifier, +}; + +#[derive(Error, Debug)] +pub enum RustX509VerifyError { + /// There was an error parsing the certificate + #[error("failed to parse certificate")] + CertificateParse(rustls::pki_types::pem::Error), + + /// There was an error building the verifier + #[error("failed to build verifier {0}")] + VerifierBuilder(VerifierBuilderError), + + /// There was an error adding the certificate to the root store. + #[error("failed to add certificate to root store {0}")] + Store(rustls::Error), +} + +/// A Rust implementation of [`RawX509Verifier`]. This does the verification +/// itself (using `rustls`) rather than delegating the work to some external +/// system. +#[derive(Debug, Clone)] +pub struct RustRawX509Verifier { + verifier: Arc, +} + +impl RustRawX509Verifier { + /// Create a new `RustRawX509Verifier` from the supplied CA certificates + /// PEM. + pub fn new_from_pem_data(ca_certs_pem: &str) -> Result { + let mut root_store = RootCertStore::empty(); + for result in CertificateDer::pem_slice_iter(ca_certs_pem.as_bytes()) { + root_store + .add(result.map_err(RustX509VerifyError::CertificateParse)?) + .map_err(RustX509VerifyError::Store)?; + } + let verifier = WebPkiClientVerifier::builder(Arc::new(root_store)) + //.with_crls(...) + .build() + .map_err(RustX509VerifyError::VerifierBuilder)?; + + Ok(Self { verifier }) + } +} + +impl RawX509Verifier for RustRawX509Verifier { + fn verify(&self, message: &[u8], sig: &RawX509Signature) -> bool { + let mut cert_iter = CertificateDer::pem_slice_iter(sig.certificate_chain.as_bytes()); + let Some(Ok(end_cert)) = cert_iter.next() else { + tracing::warn!("Missing or invalid first certificate"); + return false; + }; + let Ok(intermediate_certs): Result, _> = cert_iter.collect() else { + tracing::warn!("Invalid certificate in the chain"); + return false; + }; + + // Verify the certificate chain is issued by one of our trusted roots + if self + .verifier + .verify_client_cert(&end_cert, intermediate_certs.as_ref(), UnixTime::now()) + .is_err() + { + // Not an error: could happen if some certificate has expired. + return false; + } + + // Verify this is a signature of the master key + let Some(provider) = CryptoProvider::get_default() else { + tracing::error!("Unable to get default rustls crypto provider"); + return false; + }; + + let rustls_signature_scheme = match sig.signature_scheme { + X509SignatureScheme::RsaPssSha512 => SignatureScheme::RSA_PSS_SHA512, + }; + + let Some(alg) = provider + .signature_verification_algorithms + .mapping + .iter() + // Filter for entries with the right signature scheme + .filter(|item| item.0 == rustls_signature_scheme) + // Filter for entries with a non-empty list of algorithm implementations, and get the + // first such implementation + .filter_map(|item| item.1.first().copied()) + .next() + else { + tracing::warn!("Signature scheme {:?} not supported", sig.signature_scheme); + return false; + }; + + let Ok(cert) = EndEntityCert::try_from(&end_cert) else { + tracing::warn!("Unable to parse certificate"); + return false; + }; + + let result = cert.verify_signature(alg, message, sig.signature_bytes.as_slice()); + + if let Err(e) = result { + tracing::warn!("Signature verification failed: {e}"); + return false; + } + + true + } +} From d5f7a724964ffaa035cca6d20a6f514ba48a72c3 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 12:51:12 +0100 Subject: [PATCH 05/13] test(crypto): Test X.509 signer and verifier Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- .typos.toml | 2 + crates/matrix-sdk-crypto/src/x509/mod.rs | 120 ++++++++ .../src/x509/rust_raw_x509_signer.rs | 113 ++++++++ .../src/x509/rust_raw_x509_verifier.rs | 39 +++ .../matrix-sdk-crypto/src/x509/x509_signer.rs | 148 ++++++++++ .../matrix-sdk-crypto/src/x509/x509_verify.rs | 267 ++++++++++++++++++ 6 files changed, 689 insertions(+) diff --git a/.typos.toml b/.typos.toml index 82205b67f03..e3045261c43 100644 --- a/.typos.toml +++ b/.typos.toml @@ -11,6 +11,8 @@ extend-ignore-re = [ "[0-9+][A-Za-z0-9+]{30,}[a-z0-9+]", "\\$[A-Z0-9+][A-Za-z0-9+]{6,}[a-z0-9+]", "\\b[a-z0-9+/=][A-Za-z0-9+/=]{7,}[a-z0-9+/=][A-Z]\\b", + # pem-encoded cert chains + "-----BEGIN CERTIFICATE-----\\r?\\n(?:[A-Za-z0-9+/]{1,64}={0,2}\\r?\\n)+-----END CERTIFICATE-----" ] [default.extend-identifiers] diff --git a/crates/matrix-sdk-crypto/src/x509/mod.rs b/crates/matrix-sdk-crypto/src/x509/mod.rs index 8fa0b65d1d1..f347c0bc0a4 100644 --- a/crates/matrix-sdk-crypto/src/x509/mod.rs +++ b/crates/matrix-sdk-crypto/src/x509/mod.rs @@ -34,3 +34,123 @@ mod rust_raw_x509_verifier; pub use rust_raw_x509_signer::RustRawX509Signer; #[cfg(any(test, feature = "rust-x509-verifier-impl"))] pub use rust_raw_x509_verifier::RustRawX509Verifier; + +#[cfg(test)] +pub(crate) mod tests { + use rcgen::{Certificate, CertificateParams, CustomExtension, KeyPair, PublicKeyData, SanType}; + use ruma::OwnedUserId; + + pub(crate) const OID_PKCS9_EMAIL_ADDRESS: &[u64] = &[1, 2, 840, 113549, 1, 9, 1]; + + /// Create a certificate that contains the supplied email address in its + /// Subject Distinguished Name + /// + /// Note: https://www.rfc-editor.org/rfc/rfc5280.html#section-4.1.2.6 says: + /// + /// > Legacy implementations exist where an electronic mail address is + /// > embedded in the subject distinguished name as an emailAddress + /// > attribute [RFC2985]. + /// + /// So this is a legacy implementation. To be non-legacy it should, at a + /// minimum, include the email address in the Subject Alternative Name + /// as well as in Subject Distinguished Name. + pub(crate) fn cert_and_key_with_email_in_subject_distinguished_name( + email: &str, + ) -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let mut cert_params = CertificateParams::default(); + cert_params.use_authority_key_identifier_extension = true; + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + cert_params + .distinguished_name + .push(rcgen::DnType::from_oid(OID_PKCS9_EMAIL_ADDRESS), email); + + let cert = cert_params.self_signed(&signing_key).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Create a certificate that contains the supplied email address in its + /// Subject Alternate Name + pub(crate) fn cert_and_key_with_email_in_subject_alternate_name( + email: &str, + ) -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let mut cert_params = CertificateParams::default(); + cert_params.use_authority_key_identifier_extension = true; + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + cert_params.subject_alt_names.push(SanType::Rfc822Name( + email.try_into().expect("Failed to convert email address to Ia5String"), + )); + + let cert = cert_params.self_signed(&signing_key).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Create a certificate that contains the supplied Matrix user ID as a URI + /// in its Subject Alternate Name + pub(crate) fn cert_and_key_with_user_id_in_subject_alternate_name( + user_id: &str, + ) -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let user_id_uri = + OwnedUserId::try_from(user_id).expect("Invalid user ID!").matrix_uri(false); + + let mut cert_params = CertificateParams::default(); + cert_params.use_authority_key_identifier_extension = true; + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + cert_params.subject_alt_names.push(SanType::URI( + user_id_uri.to_string().try_into().expect("Failed to convert user_id URI to Ia5String"), + )); + + let cert = cert_params.self_signed(&signing_key).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Create a certificate that does not contain a user ID or email address + pub(crate) fn cert_and_key_with_no_user_id() -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let mut cert_params = CertificateParams::default(); + cert_params.use_authority_key_identifier_extension = true; + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + + let cert = cert_params.self_signed(&signing_key).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Build an X.509 "SubjectKeyIdentifier" extension for a cert with the + /// given public key. + pub(crate) fn subject_key_identifier_extension( + signing_key: &impl PublicKeyData, + ) -> CustomExtension { + // Ref https://www.rfc-editor.org/info/rfc5280/#section-4.2.1.2 + + use sha2::{Digest, Sha256}; + + // The actual bytes in the SKI don't actually matter that much (and the RFC just + // makes a couple of suggestions): they just need to be a reasonably + // unique way of referring to the certificate with the right public key. + let spki = signing_key.subject_public_key_info(); + let spki_hash = Sha256::digest(&spki); + let ski_bytes = &spki_hash.as_slice()[0..20]; + + // Hacky encoding of the bytes as a DER OCTET-STRING + let ski_der = [&[0x04, ski_bytes.len() as u8], ski_bytes].concat(); + + CustomExtension::from_oid_content( + &[2, 5, 29, 14], // subjectKeyIdentifier + ski_der, + ) + } +} diff --git a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs index 77d36e76d48..5c0c5b878d0 100644 --- a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs +++ b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_signer.rs @@ -107,3 +107,116 @@ impl std::fmt::Debug for RustRawX509Signer { f.debug_tuple("X509Keys").field(&"".to_owned()).finish() } } + +#[cfg(test)] +mod tests { + use crate::x509::{ + RawX509Signer, raw_x509_signature::X509SignatureScheme, + rust_raw_x509_signer::RustRawX509Signer, + }; + + #[test] + fn test_can_sign() { + let x509_sign = + RustRawX509Signer::new_from_pem_data(TEST_CERT_CHAIN, TEST_CERT_KEY).unwrap(); + + let sig = x509_sign.sign(b"hello world").unwrap(); + + assert_eq!(sig.certificate_chain, TEST_CERT_CHAIN); + assert_eq!(sig.signature_scheme, X509SignatureScheme::RsaPssSha512); + assert_eq!(sig.signature_bytes.len(), 256); + } + + /// A leaf and intermediate CA cert, generated with openssl + const TEST_CERT_CHAIN: &str = " +-----BEGIN CERTIFICATE----- +MIIEMDCCAhigAwIBAgIBATANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQGEwJVSzEP +MA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRgwFgYDVQQDDA9p +bnRlcm1lZGlhdGUtY2EwHhcNMjYwNjExMjI1MjMyWhcNMjcwNjExMjI1MjMyWjAm +MSQwIgYJKoZIhvcNAQkBFhV2ZGgteDUwOXRlc3RAc3cxdi5vcmcwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQW4+eDT5UlecWnOeLjzMIcQ/sdRb20uPF +iiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+4HCVCYHoKEc9H/Zm2W2N +63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhqrq14scYCQiFVstdtq5Pc ++F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOsNqX91Tej2tsAZvRbkBms +ILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpeiz7DESW5VGFUHwg1FdWAG +tTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8C1IoSTtVAgMBAAGjQjBA +MB0GA1UdDgQWBBSMiQQcHg6Ixffg8lknAK1wY42fjDAfBgNVHSMEGDAWgBR0DWpq +YxJI0M81uFSlqoYvckCA3jANBgkqhkiG9w0BAQsFAAOCAgEArMHX8k+xYTUVAOSR +Vzt8aMMC/LSwN0zQq/+CLX3AuhE94j+2cRM3TF5jpzeRmTObpaK88qVN5CrrK5IC +ddMvMAW+eAUyJc7aXX1tPWD0YRcWlplR0CVm6RY/VIwsVlWvhIX6fYpAJBMoYqr4 +tCYcDjA1/FNkuUQnBNnxnp0I3HQuSa1UBCCHZ/00kbVl331XY5bxZrk1OSUWP9TF +pHdW1Fgjqu0EZccjEYK7F1I+weTfkl/uOp6QUl7cmhiLnMsd0QwKUdFStOy1BYX8 +tbkMN44diYC9hRUhP9gsud12dBeF4EKl66y43zgUNPAzD3MMmU/xndWIgFs+/PnK +vlbtSMl7wdTr0bh+c3srvY4uuft2Uy0PH9mi0Sffy6LKqg5FUT6kdPtQscZYamb0 +TfJ4xjFPII7H6mkvnH/Nm0RWTvsChLeyrPQ8kl328NW0NWlitBZ0k3jiQj0Aqfim +uZwS3f03oqA/254nZU9K6SC5Nvq0L7g71XGK6ZwMVOK3Vw3TV6XLq+nO3FilSrk3 +a48QHPLwSRAs7dMIWztnycB/hyMp7daocSieza74AxSwZv6KS6ilEBFv3z9OjTS0 +Z5Zy87N5SmP89OfE92IxNQ+tYlnk2THqZSBbSj5hqdHOUVWCzbvHxFEKGSzK3+B5 +vDQS6Fr6qeVx8k6TzrvYTf9u61w= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFcDCCA1igAwIBAgIBCDANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJVSzEP +MA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRAwDgYDVQQDDAd0 +ZXN0LWNhMB4XDTI2MDYwNTE1MDIxNloXDTI3MDYwNTE1MDIxNlowTTELMAkGA1UE +BhMCVUsxDzANBgNVBAgMBkxvbmRvbjETMBEGA1UECgwKZWxlbWVudC5pbzEYMBYG +A1UEAwwPaW50ZXJtZWRpYXRlLWNhMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAt+6qbrTcH+TiHxJl7pJjIajUXvu0nYwnfqDQcaZRY0qlqPuHccBudNfk +FMoD6fchj5OvyQsZLP8UnirLxVgf6GgTLZ2JK4tTpaCFWv45ukHVYeY/r+c0fOuy +zUw4H5LljJ4ikPI3w7vncSQTOCYHpDGVhW8Mh4mAug7ODlL3RZVSsRxuikDhLQoR +OS2pRDwTFll1CYE8+MTkxPcGrnOFQMqB9290af0PnkS64BO0nPUZYqsPELkcPkGU +lc0mTWYunvfG21cU7TnPGRto4nj5l4xZBGpRZSEf3L3yh+4Chi35Rq1hWHlQ8ito +X7n1vNELkHShkQcSmALEmlspGPt6Mhjdb3Xj1N3wNlftXe7g2tFEcnpXu6EwNJYP +mljN1qEA/37xBz9JyZzeahPvOg/hF/+EX3/FcnXLDAYCbFnx/44Ljnr7MHqjQwfH +B7Ndcp22OHC6fwAEjuTPTQlbMmxrxXETKsQwg7aVIPs743R6RJeBQbkUP3hm/7G0 +uwoB2kphdjvGHYJtDguNXHuiXz/Pn6F+D6m3j/SDF4uAfnzxKDDVURPlQg2Wr2od +ez0aklHgJ9ZUAfpk3C6f1VGUmxpjVhp6nPsQ5ZgGFHNOjWZuV2+cwxahpYG6KzVD +mO/brmSc76ibzRwapIOLSjb+plbp8VGrBElIhvqRFyZmTOcGdLkCAwEAAaNjMGEw +HQYDVR0OBBYEFHQNampjEkjQzzW4VKWqhi9yQIDeMB8GA1UdIwQYMBaAFNhekZoX +8MNbE9t1Qn0hN5rfPpYRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MA0GCSqGSIb3DQEBCwUAA4ICAQA1X27xupoF0brABykMbCE4Q1QQ2u3oLUOoX0vP +EVBjGC7tlIe6O1+s8lCSSM6pAly61kMl2rIXE7GzPMLVNjg67etXfNbb8KXPqiKi +bPPLQ6Pk5/RJd3D8DUO4gKgEKtkBZK30JzKVsl5eLgxT1GVnDXgV9jNLqXuNgreZ +Ba4BYjdtR2pMbVlW0ZEda7m4N5CDh8rfJQqnZeudhzqjecXV5bxxvKotvOEgsYml +WZ4yxAwuJhlb+I/Rmc+hocsRBx8v8LIIQh/biUMaZnoCqj8d9qkpFmf9nWhXQRbJ +Sqz2ad08ZXxMY+vXHQ3mR1H4cYm5jOWuyXWSULo/WT+OrGPFrH1/E3IjhvVaal6m +c4IJQZCI7iHhyeHLxjaVmctgwV2yMUZnCuBXrv0yHkkDyr1D6OBaHF/0dYSd/rym +ZDo1S4QcKmoJJRP0nDdq7zMoLneu7Ytmn3PZ8qiAUnh5vlaQCg3SHkdQ0berp7yU ++0tFAPc7c4+yad+YBxLi+Zqplqw9Ra810Nn+coBH54+DOytWQQdK4kx0pXc+2MYR +sykQE11ItydeFcAu2Rtp6dw5G9DNOIQJWYeGOECx6phCZYrZVEPZoeF0dnsEcdP8 +F5aVeO+O1gffTCxzhLTqMXNvm86oP6QC71gC1w6eM4uuGubc56Dm4hxVywwRUGgJ +tsmVjQ== +-----END CERTIFICATE----- +"; + + /// Private key for the leaf certificate above + const TEST_CERT_KEY: &str = " +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCQW4+eDT5UlecW +nOeLjzMIcQ/sdRb20uPFiiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+ +4HCVCYHoKEc9H/Zm2W2N63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhq +rq14scYCQiFVstdtq5Pc+F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOs +NqX91Tej2tsAZvRbkBmsILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpei +z7DESW5VGFUHwg1FdWAGtTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8 +C1IoSTtVAgMBAAECggEAEOKg2SYn6wlwsxitzcl1ePCOogQtKDhRO6c1qV007Rba +wQGs/bkEXNtzGrNkcGs97gz5SNKLIEzQF+SlTo2C5Dan5IqzTeLzWVUYJDUoSXTp +wr7Meugz9T3VMwDiOrYfLfn42fY/ZmoE69+cO2Ch7lwxXX6Si8m0vTVRA10GfOmL +2xcaXTN5EcDT4aA9IuEEzs3bGd1j4+WufDtPVV5zZXQLPXzxMBqegphnqW36ACs4 +14a6HaWOfy+ggy52xxx92TeHO5Sf6MX7o2BIIEp2UXGEl8Nur7UTifZpiiqfGbxj +z2AD69Q8+Oh6qiM49JNKUJ1FKpeHrUtI/qdZpKAvuQKBgQDI+7wLvMAfQ+xRUv4u +EHhWh5uK5PTf9YojfPqGsLls5xzHlwIa2CYNbWFk7/grFL2lT0uv3beoVcxSQC8+ +ITMxrNl2OnqSwIiLF3rAIEQn/XfeiU/cdq1wakLEBdeNxCY3G4p7vk85s+KAkoH/ +ebs2ChuXnuTQGQf32S4GGdQU+QKBgQC3365jChmHK0LW8pJyjJIJrWjvQ9dLXb30 +j6dnCWPd6M0dK5+NfumOPMvw15cu6tkjs7IMwxFcnSN84h1BKe79PGSTaLl1JaOC +wo24ZGqTZZEELkehSWqc6isBce5aVInXUzBH9MSvxvLdXlW+zaItH/PvSRwO2sUS +kSY0sF0cPQKBgQCE/odt4OXlCoZLPjbydnWbFLspit47gPh7CU2iaTkaRki2DkgX +SWbMxc+IAn9eyqe/xxwXcQkB/FxrJQvd+gwtV+rCoGnRyFPSbqQMlI1lRQXYHVba +VTHpzHcHzbHYnq6HEtNtlP5J+a3tVIVvb7chSEj/6OYSii3KpU0ePmMnyQKBgGpp +oVrf9XYsqzoKmIaCo+HF4fzWnjqXvd9TY+ZVoN5EZLCFFomk8TXIKZ7wpiYY9CGd +VWXdXqbiqi8UDSoxQoZ79Rj6epo5di+uuKYGN0emeA6bWgkVnAXD36+uZ+sPEdbz +5fU+yrWPxe4nMiiCiWDkJSBOh1ZxdawRJLNJfhlhAoGBAMF2/htOA9Zucf23I/7X +xvt0tTxmh11PlmWJqAdP4w8Pi0xM6LgXpsnU8tFhK/ouHsgshcrDfZpugE7j6ZLv +xJoRUZyn77BUtdyTi/hbMsK5v9GzujTmK2hXYH+mamYRStsIZWFB1haAGT7W6njW +oJQP+ghjUYgZHGzfyheUm0L6 +-----END PRIVATE KEY----- +"; +} diff --git a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs index a60e500d361..9b862664d61 100644 --- a/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs +++ b/crates/matrix-sdk-crypto/src/x509/rust_raw_x509_verifier.rs @@ -132,3 +132,42 @@ impl RawX509Verifier for RustRawX509Verifier { true } } + +#[cfg(test)] +mod tests { + use crate::x509::{ + RawX509Signer, RawX509Verifier, rust_raw_x509_signer::RustRawX509Signer, + rust_raw_x509_verifier::RustRawX509Verifier, + tests::cert_and_key_with_email_in_subject_distinguished_name, + }; + + #[test] + fn test_can_verify() { + let (cert, signing_key) = + cert_and_key_with_email_in_subject_distinguished_name("alice@localhost"); + + let cert_pem = cert.pem(); + let key_pem = signing_key.serialize_pem(); + + let x509_sign = RustRawX509Signer::new_from_pem_data(&cert_pem, &key_pem).unwrap(); + + // After we sign a text + let sig = x509_sign.sign(b"hello world").unwrap(); + + let x509_verify = RustRawX509Verifier::new_from_pem_data(&cert_pem).unwrap(); + + // checking the signature on the same string should succeed + assert!(x509_verify.verify(b"hello world", &sig)); + + // checking the signature on a different string should fail + assert!(!x509_verify.verify(b"Hello World", &sig)); + + // checking the signature with a bad certificate should fail + let sig_with_bad_certificate_chain = { + let mut sig = sig; + sig.certificate_chain = "".to_owned(); + sig + }; + assert!(!x509_verify.verify(b"hello world", &sig_with_bad_certificate_chain)); + } +} diff --git a/crates/matrix-sdk-crypto/src/x509/x509_signer.rs b/crates/matrix-sdk-crypto/src/x509/x509_signer.rs index a66aabfd6b6..34f9832c15d 100644 --- a/crates/matrix-sdk-crypto/src/x509/x509_signer.rs +++ b/crates/matrix-sdk-crypto/src/x509/x509_signer.rs @@ -75,3 +75,151 @@ pub trait RawX509Signer: std::fmt::Debug + Send + Sync { /// Returns (key ID, signature) fn sign(&self, message: &[u8]) -> Result; } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use assert_matches::assert_matches; + use ruma::{DeviceKeyAlgorithm, DeviceKeyId, encryption::KeyUsage, user_id}; + use vodozemac::Ed25519SecretKey; + + use crate::{ + types::{CrossSigningKey, Signature, SigningKeys}, + x509::{X509Signer, rust_raw_x509_signer::RustRawX509Signer}, + }; + + #[test] + fn test_can_sign() { + let x509_signer = { + let rust_raw_x509_signer = + RustRawX509Signer::new_from_pem_data(TEST_CERT_CHAIN, TEST_CERT_KEY).unwrap(); + X509Signer::new(Arc::new(rust_raw_x509_signer)) + }; + + let user_id = user_id!("@vdh-x509test:sw1v.org").to_owned(); + + let mut cross_signing_key = { + let secret_key = Ed25519SecretKey::new(); + let public_key = secret_key.public_key(); + let keys = SigningKeys::from([( + DeviceKeyId::from_parts( + DeviceKeyAlgorithm::Ed25519, + public_key.to_base64().as_str().into(), + ), + public_key.into(), + )]); + + CrossSigningKey::new(user_id.clone(), vec![KeyUsage::Master], keys, Default::default()) + }; + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + let self_sigs = cross_signing_key.signatures.get(&user_id).unwrap(); + + // The cross-signing key should now have an X.509 signature from the + // given key. + assert_matches!( + self_sigs + .get(&DeviceKeyId::from_parts( + "io.element.x509".into(), + "2F6Rmhfww1sT23VCfSE3mt8+lhE".into() + )) + .unwrap(), + Ok(Signature::X509(_)) + ); + } + + /// A leaf and intermediate CA cert, generated with openssl + const TEST_CERT_CHAIN: &str = " +-----BEGIN CERTIFICATE----- +MIIEMDCCAhigAwIBAgIBATANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQGEwJVSzEP +MA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRgwFgYDVQQDDA9p +bnRlcm1lZGlhdGUtY2EwHhcNMjYwNjExMjI1MjMyWhcNMjcwNjExMjI1MjMyWjAm +MSQwIgYJKoZIhvcNAQkBFhV2ZGgteDUwOXRlc3RAc3cxdi5vcmcwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQW4+eDT5UlecWnOeLjzMIcQ/sdRb20uPF +iiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+4HCVCYHoKEc9H/Zm2W2N +63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhqrq14scYCQiFVstdtq5Pc ++F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOsNqX91Tej2tsAZvRbkBms +ILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpeiz7DESW5VGFUHwg1FdWAG +tTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8C1IoSTtVAgMBAAGjQjBA +MB0GA1UdDgQWBBSMiQQcHg6Ixffg8lknAK1wY42fjDAfBgNVHSMEGDAWgBR0DWpq +YxJI0M81uFSlqoYvckCA3jANBgkqhkiG9w0BAQsFAAOCAgEArMHX8k+xYTUVAOSR +Vzt8aMMC/LSwN0zQq/+CLX3AuhE94j+2cRM3TF5jpzeRmTObpaK88qVN5CrrK5IC +ddMvMAW+eAUyJc7aXX1tPWD0YRcWlplR0CVm6RY/VIwsVlWvhIX6fYpAJBMoYqr4 +tCYcDjA1/FNkuUQnBNnxnp0I3HQuSa1UBCCHZ/00kbVl331XY5bxZrk1OSUWP9TF +pHdW1Fgjqu0EZccjEYK7F1I+weTfkl/uOp6QUl7cmhiLnMsd0QwKUdFStOy1BYX8 +tbkMN44diYC9hRUhP9gsud12dBeF4EKl66y43zgUNPAzD3MMmU/xndWIgFs+/PnK +vlbtSMl7wdTr0bh+c3srvY4uuft2Uy0PH9mi0Sffy6LKqg5FUT6kdPtQscZYamb0 +TfJ4xjFPII7H6mkvnH/Nm0RWTvsChLeyrPQ8kl328NW0NWlitBZ0k3jiQj0Aqfim +uZwS3f03oqA/254nZU9K6SC5Nvq0L7g71XGK6ZwMVOK3Vw3TV6XLq+nO3FilSrk3 +a48QHPLwSRAs7dMIWztnycB/hyMp7daocSieza74AxSwZv6KS6ilEBFv3z9OjTS0 +Z5Zy87N5SmP89OfE92IxNQ+tYlnk2THqZSBbSj5hqdHOUVWCzbvHxFEKGSzK3+B5 +vDQS6Fr6qeVx8k6TzrvYTf9u61w= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFcDCCA1igAwIBAgIBCDANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJVSzEP +MA0GA1UECAwGTG9uZG9uMRMwEQYDVQQKDAplbGVtZW50LmlvMRAwDgYDVQQDDAd0 +ZXN0LWNhMB4XDTI2MDYwNTE1MDIxNloXDTI3MDYwNTE1MDIxNlowTTELMAkGA1UE +BhMCVUsxDzANBgNVBAgMBkxvbmRvbjETMBEGA1UECgwKZWxlbWVudC5pbzEYMBYG +A1UEAwwPaW50ZXJtZWRpYXRlLWNhMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAt+6qbrTcH+TiHxJl7pJjIajUXvu0nYwnfqDQcaZRY0qlqPuHccBudNfk +FMoD6fchj5OvyQsZLP8UnirLxVgf6GgTLZ2JK4tTpaCFWv45ukHVYeY/r+c0fOuy +zUw4H5LljJ4ikPI3w7vncSQTOCYHpDGVhW8Mh4mAug7ODlL3RZVSsRxuikDhLQoR +OS2pRDwTFll1CYE8+MTkxPcGrnOFQMqB9290af0PnkS64BO0nPUZYqsPELkcPkGU +lc0mTWYunvfG21cU7TnPGRto4nj5l4xZBGpRZSEf3L3yh+4Chi35Rq1hWHlQ8ito +X7n1vNELkHShkQcSmALEmlspGPt6Mhjdb3Xj1N3wNlftXe7g2tFEcnpXu6EwNJYP +mljN1qEA/37xBz9JyZzeahPvOg/hF/+EX3/FcnXLDAYCbFnx/44Ljnr7MHqjQwfH +B7Ndcp22OHC6fwAEjuTPTQlbMmxrxXETKsQwg7aVIPs743R6RJeBQbkUP3hm/7G0 +uwoB2kphdjvGHYJtDguNXHuiXz/Pn6F+D6m3j/SDF4uAfnzxKDDVURPlQg2Wr2od +ez0aklHgJ9ZUAfpk3C6f1VGUmxpjVhp6nPsQ5ZgGFHNOjWZuV2+cwxahpYG6KzVD +mO/brmSc76ibzRwapIOLSjb+plbp8VGrBElIhvqRFyZmTOcGdLkCAwEAAaNjMGEw +HQYDVR0OBBYEFHQNampjEkjQzzW4VKWqhi9yQIDeMB8GA1UdIwQYMBaAFNhekZoX +8MNbE9t1Qn0hN5rfPpYRMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MA0GCSqGSIb3DQEBCwUAA4ICAQA1X27xupoF0brABykMbCE4Q1QQ2u3oLUOoX0vP +EVBjGC7tlIe6O1+s8lCSSM6pAly61kMl2rIXE7GzPMLVNjg67etXfNbb8KXPqiKi +bPPLQ6Pk5/RJd3D8DUO4gKgEKtkBZK30JzKVsl5eLgxT1GVnDXgV9jNLqXuNgreZ +Ba4BYjdtR2pMbVlW0ZEda7m4N5CDh8rfJQqnZeudhzqjecXV5bxxvKotvOEgsYml +WZ4yxAwuJhlb+I/Rmc+hocsRBx8v8LIIQh/biUMaZnoCqj8d9qkpFmf9nWhXQRbJ +Sqz2ad08ZXxMY+vXHQ3mR1H4cYm5jOWuyXWSULo/WT+OrGPFrH1/E3IjhvVaal6m +c4IJQZCI7iHhyeHLxjaVmctgwV2yMUZnCuBXrv0yHkkDyr1D6OBaHF/0dYSd/rym +ZDo1S4QcKmoJJRP0nDdq7zMoLneu7Ytmn3PZ8qiAUnh5vlaQCg3SHkdQ0berp7yU ++0tFAPc7c4+yad+YBxLi+Zqplqw9Ra810Nn+coBH54+DOytWQQdK4kx0pXc+2MYR +sykQE11ItydeFcAu2Rtp6dw5G9DNOIQJWYeGOECx6phCZYrZVEPZoeF0dnsEcdP8 +F5aVeO+O1gffTCxzhLTqMXNvm86oP6QC71gC1w6eM4uuGubc56Dm4hxVywwRUGgJ +tsmVjQ== +-----END CERTIFICATE----- +"; + + /// Private key for the leaf certificate above + const TEST_CERT_KEY: &str = " +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCQW4+eDT5UlecW +nOeLjzMIcQ/sdRb20uPFiiRNmzS7kvWIxsTLi2t8pmhQsxqI3CdIWyWfEHy56Np+ +4HCVCYHoKEc9H/Zm2W2N63YFTZol7VKVYRAHPHdNh06y6wb1LuzTds5uSpEsvRhq +rq14scYCQiFVstdtq5Pc+F5DpLnGpo1GvzJiF1T5WpNgk9EqcP9Yso4rpwzv1TOs +NqX91Tej2tsAZvRbkBmsILyoFMe3FBwZYGy3nd56Mz/LgxJ4FZEbzHeHQ2i3jpei +z7DESW5VGFUHwg1FdWAGtTzET+eoVDRC9YzRR+Ka8yQru0kFaLAhAUjvyoMo1Nb8 +C1IoSTtVAgMBAAECggEAEOKg2SYn6wlwsxitzcl1ePCOogQtKDhRO6c1qV007Rba +wQGs/bkEXNtzGrNkcGs97gz5SNKLIEzQF+SlTo2C5Dan5IqzTeLzWVUYJDUoSXTp +wr7Meugz9T3VMwDiOrYfLfn42fY/ZmoE69+cO2Ch7lwxXX6Si8m0vTVRA10GfOmL +2xcaXTN5EcDT4aA9IuEEzs3bGd1j4+WufDtPVV5zZXQLPXzxMBqegphnqW36ACs4 +14a6HaWOfy+ggy52xxx92TeHO5Sf6MX7o2BIIEp2UXGEl8Nur7UTifZpiiqfGbxj +z2AD69Q8+Oh6qiM49JNKUJ1FKpeHrUtI/qdZpKAvuQKBgQDI+7wLvMAfQ+xRUv4u +EHhWh5uK5PTf9YojfPqGsLls5xzHlwIa2CYNbWFk7/grFL2lT0uv3beoVcxSQC8+ +ITMxrNl2OnqSwIiLF3rAIEQn/XfeiU/cdq1wakLEBdeNxCY3G4p7vk85s+KAkoH/ +ebs2ChuXnuTQGQf32S4GGdQU+QKBgQC3365jChmHK0LW8pJyjJIJrWjvQ9dLXb30 +j6dnCWPd6M0dK5+NfumOPMvw15cu6tkjs7IMwxFcnSN84h1BKe79PGSTaLl1JaOC +wo24ZGqTZZEELkehSWqc6isBce5aVInXUzBH9MSvxvLdXlW+zaItH/PvSRwO2sUS +kSY0sF0cPQKBgQCE/odt4OXlCoZLPjbydnWbFLspit47gPh7CU2iaTkaRki2DkgX +SWbMxc+IAn9eyqe/xxwXcQkB/FxrJQvd+gwtV+rCoGnRyFPSbqQMlI1lRQXYHVba +VTHpzHcHzbHYnq6HEtNtlP5J+a3tVIVvb7chSEj/6OYSii3KpU0ePmMnyQKBgGpp +oVrf9XYsqzoKmIaCo+HF4fzWnjqXvd9TY+ZVoN5EZLCFFomk8TXIKZ7wpiYY9CGd +VWXdXqbiqi8UDSoxQoZ79Rj6epo5di+uuKYGN0emeA6bWgkVnAXD36+uZ+sPEdbz +5fU+yrWPxe4nMiiCiWDkJSBOh1ZxdawRJLNJfhlhAoGBAMF2/htOA9Zucf23I/7X +xvt0tTxmh11PlmWJqAdP4w8Pi0xM6LgXpsnU8tFhK/ouHsgshcrDfZpugE7j6ZLv +xJoRUZyn77BUtdyTi/hbMsK5v9GzujTmK2hXYH+mamYRStsIZWFB1haAGT7W6njW +oJQP+ghjUYgZHGzfyheUm0L6 +-----END PRIVATE KEY----- +"; +} diff --git a/crates/matrix-sdk-crypto/src/x509/x509_verify.rs b/crates/matrix-sdk-crypto/src/x509/x509_verify.rs index 19b532cf367..d60ce3d314e 100644 --- a/crates/matrix-sdk-crypto/src/x509/x509_verify.rs +++ b/crates/matrix-sdk-crypto/src/x509/x509_verify.rs @@ -235,3 +235,270 @@ fn get_attribute_value_as_string(value: &AttributeValue) -> Option<&str> { _ => None, } } + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + + use cms::cert::x509::der::Decode; + use rcgen::generate_simple_self_signed; + use ruma::{DeviceKeyAlgorithm, DeviceKeyId, encryption::KeyUsage, user_id}; + use vodozemac::Ed25519SecretKey; + + use super::*; + use crate::{ + types::{CrossSigningKey, SigningKeys}, + x509::{ + X509Signer, + rust_raw_x509_signer::RustRawX509Signer, + rust_raw_x509_verifier::RustRawX509Verifier, + tests::{ + cert_and_key_with_email_in_subject_alternate_name, + cert_and_key_with_email_in_subject_distinguished_name, + cert_and_key_with_no_user_id, cert_and_key_with_user_id_in_subject_alternate_name, + }, + }, + }; + + #[test] + fn test_can_extract_email_address_from_a_cert_sdn() { + // Given a certificate containing an email address in the Subject + // Distinguished Name + let (cert, _) = + cert_and_key_with_email_in_subject_distinguished_name("myname@company.co.uk"); + + // When we extract the email address it contains + let email = get_email_address_from_certificate(&Certificate::from_der(cert.der()).unwrap()) + .expect("Failed to get email address from cert"); + + // Then it matches what we put in + assert_eq!(email, "myname@company.co.uk"); + } + + #[test] + fn test_can_extract_email_address_from_a_cert_san() { + // Given a certificate containing an email address in the Subject + // Alternative Name + let (cert, _) = cert_and_key_with_email_in_subject_alternate_name("myname@company.co.uk"); + + // When we extract the email address it contains + let email = get_email_address_from_certificate(&Certificate::from_der(cert.der()).unwrap()) + .expect("Failed to get email address from cert"); + + // Then it matches what we put in + assert_eq!(email, "myname@company.co.uk"); + } + + #[test] + fn test_can_extract_user_id_from_a_cert_san() { + // Given a certificate containing a user ID in the Subject Alternative + // Name + let (cert, _) = + cert_and_key_with_user_id_in_subject_alternate_name("@myname:company.co.uk"); + + // When we extract the user ID it contains + let user_id = get_user_id_from_certificate(&Certificate::from_der(cert.der()).unwrap()) + .expect("Failed to get email address from cert"); + + // Then it matches what we put in + assert_eq!(user_id, "@myname:company.co.uk"); + } + + #[test] + fn test_extract_email_address_from_a_cert_that_does_not_contain_one_returns_none() { + // Given a certificate not containing an email address + let cert = generate_simple_self_signed(&[]).expect("Failed to generate cert"); + + // When we attempt to extract the email address + let email = + get_email_address_from_certificate(&Certificate::from_der(cert.cert.der()).unwrap()); + + // Then the answer is empty + assert!(email.is_none()); + } + + #[test] + fn test_extract_user_id_from_a_cert_that_does_not_contain_one_returns_none() { + // Given a certificate not containing an email address + let cert = generate_simple_self_signed(&[]).expect("Failed to generate cert"); + + // When we attempt to extract the email address + let user_id = + get_user_id_from_certificate(&Certificate::from_der(cert.cert.der()).unwrap()); + + // Then the answer is empty + assert!(user_id.is_none()); + } + + #[test] + fn test_can_verify_cert_containing_email_in_dn() { + // Given a cert containing the email address in the Subject Distinguished Name + let (cert, signing_key) = + cert_and_key_with_email_in_subject_distinguished_name("alice@localhost"); + + // And a cross-signing key + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + // When we attempt to verify it without signing, then it fails + assert!(!x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + + // But when we sign it + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it verifies correctly + assert!(x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_can_verify_cert_containing_email_in_san() { + // Given a cert containing the email address in the Subject Alternative + // Name + let (cert, signing_key) = + cert_and_key_with_email_in_subject_alternate_name("alice@localhost"); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it verifies correctly. + assert!(x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_can_verify_cert_containing_username_in_san() { + // Given a cert containing the Matrix user ID in the Subject Alternative + // Name + let (cert, signing_key) = + cert_and_key_with_user_id_in_subject_alternate_name("@alice:localhost"); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it verifies correctly. + assert!(x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_verification_fails_if_dn_email_is_wrong() { + // Given a cert containing an incorrect email address in the Subject + // Distinguished Name + let (cert, signing_key) = + cert_and_key_with_email_in_subject_distinguished_name("bob@localhost"); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it fails to verify because the supplied email address translates + // to a different user ID. + assert!(!x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_verification_fails_if_san_email_is_wrong() { + // Given a cert containing an incorrect email address in the Subject + // Alternative Name + let (cert, signing_key) = + cert_and_key_with_email_in_subject_alternate_name("bob@localhost"); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it fails to verify because the supplied email address translates + // to a different user ID. + assert!(!x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_verification_fails_if_cert_user_id_is_wrong() { + // Given a cert containing an incorrect email address in the Subject + // Alternative Name + let (cert, signing_key) = + cert_and_key_with_user_id_in_subject_alternate_name("@bob:localhost"); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it fails to verify because the supplied user ID does not match + // the signing user. + assert!(!x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + #[test] + fn test_verification_fails_if_cert_user_id_is_missing() { + // Given a cert with no email or user ID at all + let (cert, signing_key) = cert_and_key_with_no_user_id(); + + // When we sign a cross-signing key using it + let (x509_signer, x509_verifier) = create_signer_and_verifier(cert, signing_key); + + let user_id = user_id!("@alice:localhost").to_owned(); + let mut cross_signing_key = create_cross_signing_key(&user_id); + + x509_signer.sign_cross_signing_key(&user_id, &mut cross_signing_key).unwrap(); + + // Then it fails to verify because there is no user ID to check against + // the user's ID. + assert!(!x509_verifier.verify_signed_object(&user_id, &cross_signing_key)); + } + + fn create_cross_signing_key(user_id: &UserId) -> CrossSigningKey { + let secret_key = Ed25519SecretKey::new(); + let public_key = secret_key.public_key(); + let keys = SigningKeys::from([( + DeviceKeyId::from_parts( + DeviceKeyAlgorithm::Ed25519, + public_key.to_base64().as_str().into(), + ), + public_key.into(), + )]); + + CrossSigningKey::new(user_id.to_owned(), vec![KeyUsage::Master], keys, Default::default()) + } + + fn create_signer_and_verifier( + cert: rcgen::Certificate, + signing_key: rcgen::KeyPair, + ) -> (X509Signer, X509Verifier) { + let cert_pem = cert.pem(); + let key_pem = signing_key.serialize_pem(); + + let x509_signer = { + let rust_raw_x509_signer = + RustRawX509Signer::new_from_pem_data(&cert_pem, &key_pem).unwrap(); + X509Signer::new(Arc::new(rust_raw_x509_signer)) + }; + + let x509_verifier = { + let rust_raw_x509_verifier = RustRawX509Verifier::new_from_pem_data(&cert_pem).unwrap(); + X509Verifier::new(Arc::new(rust_raw_x509_verifier)) + }; + (x509_signer, x509_verifier) + } +} From 074279163ef9f0ab9be92a4e58f3408442f04d82 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 3 Jul 2026 15:31:28 +0100 Subject: [PATCH 06/13] feat(crypto): Store optional X.509 signer, verifier on `StoreInner` Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/store/mod.rs | 55 ++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-crypto/src/store/mod.rs b/crates/matrix-sdk-crypto/src/store/mod.rs index 1904a517d77..9eb84bc7be7 100644 --- a/crates/matrix-sdk-crypto/src/store/mod.rs +++ b/crates/matrix-sdk-crypto/src/store/mod.rs @@ -115,6 +115,8 @@ use self::caches::{SequenceNumber, StoreCache, StoreCacheGuard, UsersForKeyQuery use crate::types::{ events::room_key_withheld::RoomKeyWithheldContent, room_history::RoomKeyBundle, }; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::x509::{X509Signer, X509Verifier}; pub use crate::{ dehydrated_devices::DehydrationError, gossiping::{GossipRequest, SecretInfo}, @@ -489,6 +491,16 @@ struct StoreInner { verification_machine: VerificationMachine, + /// If we can verify peoples' identities via X.509 certificates, a handle to + /// the implementation. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: Option, + + /// If we can sign our own identity via X.509 certificates, a handle to the + /// implementation. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: Option, + /// Static account data that never changes (and thus can be loaded once and /// for all when creating the store). static_account: StaticAccountData, @@ -543,12 +555,35 @@ pub enum SecretsBundleExportError { } impl Store { - /// Create a new Store. + /// Create a new Store. A thin wrapper for [`Store::new_with_x509`]. pub(crate) fn new( account: StaticAccountData, identity: Arc>, store: Arc, verification_machine: VerificationMachine, + ) -> Self { + Self::new_with_x509( + account, + identity, + store, + verification_machine, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + } + + /// Create a new Store, allowing specification of X.509 parameters. + pub(crate) fn new_with_x509( + account: StaticAccountData, + identity: Arc>, + store: Arc, + verification_machine: VerificationMachine, + #[cfg(feature = "experimental-x509-identity-verification")] x509_verifier: Option< + X509Verifier, + >, + #[cfg(feature = "experimental-x509-identity-verification")] x509_signer: Option, ) -> Self { Self { inner: Arc::new(StoreInner { @@ -556,6 +591,10 @@ impl Store { identity, store: store.clone(), verification_machine, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer, cache: Arc::new(RwLock::new(StoreCache { store, tracked_users: Default::default(), @@ -581,6 +620,20 @@ impl Store { &self.inner.static_account } + /// If we can sign our own identity via X.509 certificates, a handle to the + /// implementation. + #[cfg(feature = "experimental-x509-identity-verification")] + pub(crate) fn x509_signer(&self) -> Option<&X509Signer> { + self.inner.x509_signer.as_ref() + } + + /// If we can verify peoples' identities via X.509 certificates, a handle to + /// the implementation. + #[cfg(feature = "experimental-x509-identity-verification")] + pub(crate) fn x509_verifier(&self) -> Option<&X509Verifier> { + self.inner.x509_verifier.as_ref() + } + pub(crate) async fn cache(&self) -> Result { Ok(StoreCacheGuard { cache: self.inner.cache.clone().read_owned().await }) } From 494e115ddfe59d6cbb6ce6294501dc9d4db65c0a Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 3 Jul 2026 16:55:13 +0100 Subject: [PATCH 07/13] feat(crypto): Thread X.509 signer through cross-signing bootstrap Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- .../src/identities/room_identity_state.rs | 20 ++++- crates/matrix-sdk-crypto/src/machine/mod.rs | 8 +- crates/matrix-sdk-crypto/src/olm/account.rs | 18 +++- .../src/olm/group_sessions/sender_data.rs | 7 +- .../olm/group_sessions/sender_data_finder.rs | 10 ++- .../matrix-sdk-crypto/src/olm/signing/mod.rs | 82 +++++++++++++++++-- .../matrix-sdk-crypto/src/verification/mod.rs | 14 +++- 7 files changed, 140 insertions(+), 19 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs index 313e0682d8c..618d4c5b036 100644 --- a/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs +++ b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs @@ -1111,8 +1111,14 @@ mod tests { let device_id = owned_device_id!("DEV123"); let account = Account::with_device_id(user_id, &device_id); - let private_identity = - Arc::new(Mutex::new(PrivateCrossSigningIdentity::for_account(&account).unwrap())); + let private_identity = Arc::new(Mutex::new( + PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(), + )); let other_user_identity_data = OtherUserIdentityData::from_private(&*private_identity.lock().await).await; @@ -1151,8 +1157,14 @@ mod tests { let device_id = owned_device_id!("DEV123"); let account = Account::with_device_id(user_id, &device_id); - let private_identity = - Arc::new(Mutex::new(PrivateCrossSigningIdentity::for_account(&account).unwrap())); + let private_identity = Arc::new(Mutex::new( + PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(), + )); let own_user_identity_data = OwnUserIdentityData::from_private(&*private_identity.lock().await).await; diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 4e7e3ad398d..827bef2c74c 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -674,6 +674,7 @@ impl OlmMachine { } AnyIncomingResponse::SignatureUpload(_) => { self.inner.verification_machine.mark_request_as_sent(request_id); + self.inner.key_request_machine.mark_outgoing_request_as_sent(request_id).await?; } AnyIncomingResponse::RoomMessage(_) => { self.inner.verification_machine.mark_request_as_sent(request_id); @@ -731,7 +732,12 @@ impl OlmMachine { let (identity, upload_signing_keys_req, upload_signatures_req) = { let cache = self.inner.store.cache().await?; let account = cache.account().await?; - account.bootstrap_cross_signing().await? + account + .bootstrap_cross_signing( + #[cfg(feature = "experimental-x509-identity-verification")] + self.inner.store.x509_signer(), + ) + .await? }; let public = identity.to_public_identity().await.expect( diff --git a/crates/matrix-sdk-crypto/src/olm/account.rs b/crates/matrix-sdk-crypto/src/olm/account.rs index e460d71153d..edb3e22f0c2 100644 --- a/crates/matrix-sdk-crypto/src/olm/account.rs +++ b/crates/matrix-sdk-crypto/src/olm/account.rs @@ -56,6 +56,8 @@ use super::{ }; #[cfg(feature = "experimental-algorithms")] use crate::types::events::room::encrypted::OlmV2Curve25519AesSha2Content; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::x509::X509Signer; use crate::{ DecryptionSettings, Device, OlmError, SignatureError, TrustRequirement, dehydrated_devices::DehydrationError, @@ -814,11 +816,18 @@ impl Account { /// this device to the server. pub async fn bootstrap_cross_signing( &self, + #[cfg(feature = "experimental-x509-identity-verification")] x509_signer: Option< + &X509Signer, + >, ) -> Result< (PrivateCrossSigningIdentity, UploadSigningKeysRequest, SignatureUploadRequest), SignatureError, > { - let identity = PrivateCrossSigningIdentity::for_account(self)?; + let identity = PrivateCrossSigningIdentity::for_account( + self, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer, + )?; let signature_request = identity.sign_account(self.static_data()).await?; @@ -832,10 +841,13 @@ impl Account { &self, cross_signing_key: &mut CrossSigningKey, ) -> Result<(), SignatureError> { - let signature = self.sign_json(to_canonical_value(&cross_signing_key)?)?; + let canonical_json = to_canonical_value(&cross_signing_key)?; + let signer = self.user_id().to_owned(); + + let signature = self.sign_json(canonical_json)?; cross_signing_key.signatures.add_signature( - self.user_id().to_owned(), + signer, DeviceKeyId::from_parts(DeviceKeyAlgorithm::Ed25519, self.device_id()), signature, ); diff --git a/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data.rs index 6e559abfeb0..d5a007ade2d 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data.rs @@ -796,7 +796,12 @@ mod tests { async fn test_from_device_for_verified_user() { let alice_account = Account::with_device_id(user_id!("@alice:example.com"), device_id!("ALICE_DEVICE")); - let alice_identity = PrivateCrossSigningIdentity::for_account(&alice_account).unwrap(); + let alice_identity = PrivateCrossSigningIdentity::for_account( + &alice_account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let bob_identity = PrivateCrossSigningIdentity::new(owned_user_id!("@bob:example.com")); let bob_account = diff --git a/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data_finder.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data_finder.rs index e233fdcc65c..8d172c170f3 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data_finder.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/sender_data_finder.rs @@ -913,8 +913,14 @@ mod tests { ) -> Self { let account = Account::with_device_id(user_id, device_id); let user_id = user_id.to_owned(); - let private_identity = - Arc::new(Mutex::new(PrivateCrossSigningIdentity::for_account(&account).unwrap())); + let private_identity = Arc::new(Mutex::new( + PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(), + )); let user_identity = create_user_identity(&*private_identity.lock().await, is_me, is_verified, signer) diff --git a/crates/matrix-sdk-crypto/src/olm/signing/mod.rs b/crates/matrix-sdk-crypto/src/olm/signing/mod.rs index 30235efcf7d..a53a0ff259e 100644 --- a/crates/matrix-sdk-crypto/src/olm/signing/mod.rs +++ b/crates/matrix-sdk-crypto/src/olm/signing/mod.rs @@ -30,12 +30,14 @@ use tokio::sync::Mutex; use vodozemac::Ed25519Signature; use super::StaticAccountData; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::x509::X509Signer; use crate::{ Account, DeviceData, OtherUserIdentityData, OwnUserIdentity, OwnUserIdentityData, error::SignatureError, store::SecretImportError, types::{ - DeviceKeys, MasterPubkey, SelfSigningPubkey, UserSigningPubkey, + CrossSigningKey, DeviceKeys, MasterPubkey, SelfSigningPubkey, UserSigningPubkey, requests::UploadSigningKeysRequest, }, }; @@ -566,10 +568,23 @@ impl PrivateCrossSigningIdentity { */ pub(crate) fn for_account( account: &Account, + #[cfg(feature = "experimental-x509-identity-verification")] x509_signer: Option< + &X509Signer, + >, ) -> Result { let mut master = MasterSigning::new(account.user_id().into()); - account.sign_cross_signing_key(master.public_key_mut().as_mut())?; + // TODO: AJB: some duplication with + // matrix_sdk_crypto::identities::user::OwnUserIdentity::verify + + let cross_signing_key: &mut CrossSigningKey = &mut *master.public_key_mut().as_mut(); + + account.sign_cross_signing_key(cross_signing_key)?; + + #[cfg(feature = "experimental-x509-identity-verification")] + if let Some(x509_signer) = x509_signer { + x509_signer.sign_cross_signing_key(&account.user_id, cross_signing_key)?; + } Ok(Self::new_helper(account.user_id(), master)) } @@ -749,7 +764,12 @@ mod tests { #[async_test] async fn test_private_identity_signed_by_account() { let account = Account::with_device_id(user_id(), device_id!("DEVICEID")); - let identity = PrivateCrossSigningIdentity::for_account(&account).unwrap(); + let identity = PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let master = identity.master_key.lock().await; let master = master.as_ref().unwrap(); @@ -769,10 +789,50 @@ mod tests { assert!(!master.public_key().signatures().is_empty()); } + #[cfg(feature = "experimental-x509-identity-verification")] + #[async_test] + async fn test_private_identity_signed_by_x509() { + use crate::x509::{RustRawX509Signer, RustRawX509Verifier, X509Signer, X509Verifier}; + + let account = Account::with_device_id(user_id(), device_id!("DEVICEID")); + let (cert, signing_key) = + crate::x509::tests::cert_and_key_with_email_in_subject_distinguished_name( + "example@localhost", + ); + + let cert_pem = cert.pem(); + let key_pem = signing_key.serialize_pem(); + let x509_signer = { + let rust_raw_x509_signer = + RustRawX509Signer::new_from_pem_data(&cert_pem, &key_pem).unwrap(); + X509Signer::new(Arc::new(rust_raw_x509_signer)) + }; + + // When we pass in an X509Signer to for_account, ... + let identity = + PrivateCrossSigningIdentity::for_account(&account, Some(&x509_signer)).unwrap(); + let master = identity.master_key.lock().await; + let master = master.as_ref().unwrap(); + + let public_key = master.public_key().as_ref(); + + // ... the resulting cross-signing identity should be signed with X.509 + let x509_verifier = { + let rust_raw_x509_verifier = RustRawX509Verifier::new_from_pem_data(&cert_pem).unwrap(); + X509Verifier::new(Arc::new(rust_raw_x509_verifier)) + }; + assert!(x509_verifier.verify_signed_object(user_id(), public_key)); + } + #[async_test] async fn test_sign_device() { let account = Account::with_device_id(user_id(), device_id!("DEVICEID")); - let identity = PrivateCrossSigningIdentity::for_account(&account).unwrap(); + let identity = PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let mut device = DeviceData::from_account(&account); let self_signing = identity.self_signing_key.lock().await; @@ -789,11 +849,21 @@ mod tests { #[async_test] async fn test_sign_user_identity() { let account = Account::with_device_id(user_id(), device_id!("DEVICEID")); - let identity = PrivateCrossSigningIdentity::for_account(&account).unwrap(); + let identity = PrivateCrossSigningIdentity::for_account( + &account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let bob_account = Account::with_device_id(user_id!("@bob:localhost"), device_id!("DEVICEID")); - let bob_private = PrivateCrossSigningIdentity::for_account(&bob_account).unwrap(); + let bob_private = PrivateCrossSigningIdentity::for_account( + &bob_account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let mut bob_public = OtherUserIdentityData::from_private(&bob_private).await; let user_signing = identity.user_signing_key.lock().await; diff --git a/crates/matrix-sdk-crypto/src/verification/mod.rs b/crates/matrix-sdk-crypto/src/verification/mod.rs index 5606f6804fa..b41a2c737a1 100644 --- a/crates/matrix-sdk-crypto/src/verification/mod.rs +++ b/crates/matrix-sdk-crypto/src/verification/mod.rs @@ -830,12 +830,22 @@ pub(crate) mod tests { pub(crate) async fn setup_stores() -> (Account, VerificationStore, Account, VerificationStore) { let alice = Account::with_device_id(alice_id(), alice_device_id()); let alice_store = MemoryStore::new(); - let alice_private_identity = PrivateCrossSigningIdentity::for_account(&alice).unwrap(); + let alice_private_identity = PrivateCrossSigningIdentity::for_account( + &alice, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let alice_private_identity = Mutex::new(alice_private_identity); let bob = Account::with_device_id(bob_id(), bob_device_id()); let bob_store = MemoryStore::new(); - let bob_private_identity = PrivateCrossSigningIdentity::for_account(&bob).unwrap(); + let bob_private_identity = PrivateCrossSigningIdentity::for_account( + &bob, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); let bob_private_identity = Mutex::new(bob_private_identity); let alice_public_identity = From d83988506ecf68a6655d1315c8dfea48f7ee539d Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 3 Jul 2026 16:55:13 +0100 Subject: [PATCH 08/13] feat(crypto): Dual-sign master key in `OwnUserIdentity::verify` Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- .../matrix-sdk-crypto/src/identities/user.rs | 121 +++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-crypto/src/identities/user.rs b/crates/matrix-sdk-crypto/src/identities/user.rs index 706c5c3f850..2e987236803 100644 --- a/crates/matrix-sdk-crypto/src/identities/user.rs +++ b/crates/matrix-sdk-crypto/src/identities/user.rs @@ -231,6 +231,8 @@ impl OwnUserIdentity { let cache = self.store.cache().await?; let account = cache.account().await?; + #[cfg(feature = "experimental-x509-identity-verification")] + let x509_signer = self.store.x509_signer(); let public_key = self .master_key @@ -243,6 +245,14 @@ impl OwnUserIdentity { cross_signing_key.signatures.clear(); account.sign_cross_signing_key(&mut cross_signing_key)?; + // TODO: AJB: duplicate of + // matrix_sdk_crypto::olm::signing::PrivateCrossSigningIdentity::for_account + + #[cfg(feature = "experimental-x509-identity-verification")] + if let Some(x509_signer) = x509_signer { + x509_signer.sign_cross_signing_key(&self.user_id, &mut cross_signing_key)?; + } + let mut user_signed_keys = SignedKeys::new(); user_signed_keys.add_cross_signing_keys(public_key, cross_signing_key.to_raw()); @@ -1453,6 +1463,14 @@ pub(crate) mod tests { types::{CrossSigningKey, MasterPubkey, SelfSigningPubkey, Signatures, UserSigningPubkey}, verification::VerificationMachine, }; + #[cfg(feature = "experimental-x509-identity-verification")] + use crate::{ + store::Store, + x509::{ + RustRawX509Signer, RustRawX509Verifier, X509Signer, X509Verifier, + tests::cert_and_key_with_email_in_subject_distinguished_name, + }, + }; #[test] fn own_identity_create() { @@ -2014,6 +2032,53 @@ pub(crate) mod tests { ); } + #[cfg(feature = "experimental-x509-identity-verification")] + #[async_test] + async fn test_sign_own_identity_with_x509() { + let account = + Account::with_device_id(user_id!("@own_user:localhost"), device_id!("DEV123")); + // We create a store with an X.509 signer + let (cert, signing_key) = + cert_and_key_with_email_in_subject_distinguished_name("own_user@localhost"); + + let cert_pem = cert.pem(); + let key_pem = signing_key.serialize_pem(); + + let x509_signer = { + let rust_raw_x509_signer = + RustRawX509Signer::new_from_pem_data(&cert_pem, &key_pem).unwrap(); + X509Signer::new(Arc::new(rust_raw_x509_signer)) + }; + + let x509_verifier = { + let rust_raw_x509_verifier = RustRawX509Verifier::new_from_pem_data(&cert_pem).unwrap(); + X509Verifier::new(Arc::new(rust_raw_x509_verifier)) + }; + + let store = create_store_with_x509(account, x509_verifier.clone(), x509_signer).await; + + // When we verify our own identity, the uploaded identity key should be + // signed using X.509. + let own_identity = store + .get_identity(user_id!("@own_user:localhost")) + .await + .unwrap() + .unwrap() + .own() + .unwrap(); + let signature_upload_request = own_identity.verify().await.unwrap(); + let (_, signed_key) = signature_upload_request + .signed_keys + .get(user_id!("@own_user:localhost")) + .unwrap() + .iter() + .next() + .unwrap(); + let signed_key: CrossSigningKey = serde_json::from_str(signed_key.get()).unwrap(); + + assert!(x509_verifier.verify_signed_object(user_id!("@own_user:localhost"), &signed_key)); + } + /// Create an [`OtherUserIdentity`] for use in tests async fn other_user_identity() -> OtherUserIdentity { use ruma::owned_device_id; @@ -2040,7 +2105,12 @@ pub(crate) mod tests { * Creates a new private user identity for the account. */ fn get_verification_machine(account: &Account) -> VerificationMachine { - let private_identity = PrivateCrossSigningIdentity::for_account(account).unwrap(); + let private_identity = PrivateCrossSigningIdentity::for_account( + account, + #[cfg(feature = "experimental-x509-identity-verification")] + None, + ) + .unwrap(); VerificationMachine::new( account.static_data().clone(), Arc::new(Mutex::new(private_identity)), @@ -2051,4 +2121,53 @@ pub(crate) mod tests { )), ) } + + /** + * Creates a crypto store, backed by a [`MemoryStore`], for the given + * account, with an X.509 verifier and signer. + */ + #[cfg(feature = "experimental-x509-identity-verification")] + async fn create_store_with_x509( + account: Account, + x509_verifier: X509Verifier, + x509_signer: X509Signer, + ) -> Store { + use crate::store::types::{Changes, IdentityChanges, PendingChanges}; + + let account_static_data = account.static_data().clone(); + let private_identity = PrivateCrossSigningIdentity::for_account(&account, None).unwrap(); + + let crypto_store_wrapper = + CryptoStoreWrapper::new(account.user_id(), account.device_id(), MemoryStore::new()); + crypto_store_wrapper + .save_pending_changes(PendingChanges { account: Some(account) }) + .await + .unwrap(); + let changes = Changes { + private_identity: Some(private_identity.clone()), + identities: IdentityChanges { + changed: vec![private_identity.to_public_identity().await.unwrap().into()], + ..Default::default() + }, + ..Default::default() + }; + crypto_store_wrapper.save_changes(changes).await.unwrap(); + + let crypto_store_wrapper = Arc::new(crypto_store_wrapper); + let private_identity = Arc::new(Mutex::new(private_identity)); + let verification_machine = VerificationMachine::new( + account_static_data.clone(), + private_identity.clone(), + crypto_store_wrapper.clone(), + ); + + Store::new_with_x509( + account_static_data.clone(), + private_identity, + crypto_store_wrapper, + verification_machine, + Some(x509_verifier), + Some(x509_signer), + ) + } } From 4d7c039b03241ef6bf94bc1349ab7a64bd00a17d Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 10:31:18 +0100 Subject: [PATCH 09/13] feat(crypto): Inject X.509 signer and verifier via `OlmMachineBuilder` Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/machine/mod.rs | 79 +++++++++++++++++++-- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 827bef2c74c..8190971d802 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -70,6 +70,8 @@ use vodozemac::{Curve25519PublicKey, Ed25519Signature, megolm::DecryptionError}; use crate::error::SecretPushError; #[cfg(feature = "experimental-send-custom-to-device")] use crate::session_manager::split_devices_for_share_strategy; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::x509::{X509Signer, X509Verifier}; use crate::{ CollectStrategy, CryptoStoreError, DecryptionSettings, DeviceData, LocalTrust, RoomEventDecryptionResult, SignatureError, TrustRequirement, @@ -142,6 +144,14 @@ pub struct OlmMachineBuilder { /// Optional override for the vodozemac `Account` that will be used for this /// OlmMachine. custom_account: Option, + + /// Optional X509 verifier to be used for verifying people's identities. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: Option, + + /// Optional X509 signer to be used for signing our own identity. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: Option, } impl OlmMachineBuilder { @@ -153,6 +163,10 @@ impl OlmMachineBuilder { device_id: device_id.to_owned(), store: None, custom_account: None, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: None, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: None, } } @@ -182,6 +196,22 @@ impl OlmMachineBuilder { self } + /// Specify an [`X509Verifier`] which the `OlmMachine` should use to verify + /// people's identities. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn with_x509_verifier(mut self, x509_verifier: Option) -> Self { + self.x509_verifier = x509_verifier; + self + } + + /// Specify an [`X509Signer`] which the `OlmMachine` should use to sign + /// our own identity. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn with_x509_signer(mut self, x509_signer: Option) -> Self { + self.x509_signer = x509_signer; + self + } + /// Construct a new [`OlmMachine`] from this builder. /// /// If a store was given via [`OlmMachineBuilder::with_crypto_store`], and @@ -291,8 +321,15 @@ impl OlmMachine { }) .await?; - let (verification_machine, store, identity_manager) = - Self::new_helper_prelude(store, static_account, self.store().private_identity()); + let (verification_machine, store, identity_manager) = Self::new_helper_prelude( + store, + static_account, + self.store().private_identity(), + #[cfg(feature = "experimental-x509-identity-verification")] + self.store().x509_verifier().cloned(), + #[cfg(feature = "experimental-x509-identity-verification")] + self.store().x509_signer().cloned(), + ); Ok(Self::new_helper( device_id, @@ -308,10 +345,24 @@ impl OlmMachine { store_wrapper: Arc, account: StaticAccountData, user_identity: Arc>, + #[cfg(feature = "experimental-x509-identity-verification")] x509_verifier: Option< + X509Verifier, + >, + #[cfg(feature = "experimental-x509-identity-verification")] x509_signer: Option, ) -> (VerificationMachine, Store, IdentityManager) { let verification_machine = VerificationMachine::new(account.clone(), user_identity.clone(), store_wrapper.clone()); - let store = Store::new(account, user_identity, store_wrapper, verification_machine.clone()); + + let store = Store::new_with_x509( + account, + user_identity, + store_wrapper, + verification_machine.clone(), + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer, + ); let identity_manager = IdentityManager::new(store.clone()); @@ -359,7 +410,16 @@ impl OlmMachine { #[instrument(skip(builder), fields(user_id, device_id, ed25519_key, curve25519_key))] pub(crate) async fn from_builder(builder: OlmMachineBuilder) -> StoreResult { - let OlmMachineBuilder { user_id, device_id, store, custom_account } = builder; + let OlmMachineBuilder { + user_id, + device_id, + store, + custom_account, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer, + } = builder; let store = store.unwrap_or_else(|| MemoryStore::new().into_crypto_store()); @@ -449,8 +509,15 @@ impl OlmMachine { let identity = Arc::new(Mutex::new(identity)); let store = Arc::new(CryptoStoreWrapper::new(&user_id, &device_id, store)); - let (verification_machine, store, identity_manager) = - Self::new_helper_prelude(store, static_account, identity.clone()); + let (verification_machine, store, identity_manager) = Self::new_helper_prelude( + store, + static_account, + identity.clone(), + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer, + ); // FIXME: We might want in the future a more generic high-level data migration // mechanism (at the store wrapper layer). From c50cd04b3e1f2d91b574dfb8dd0e9426eb732c78 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 10:31:18 +0100 Subject: [PATCH 10/13] feat(crypto): Verify other identities via X.509 master-key sigs Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- .../src/identities/room_identity_state.rs | 2 + .../matrix-sdk-crypto/src/identities/user.rs | 167 +++++++++++++++++- .../group_sessions/share_strategy.rs | 2 + 3 files changed, 164 insertions(+), 7 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs index 618d4c5b036..a0d7eaf4ed8 100644 --- a/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs +++ b/crates/matrix-sdk-crypto/src/identities/room_identity_state.rs @@ -1137,6 +1137,8 @@ mod tests { MemoryStore::new(), )), ), + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: None, }) } diff --git a/crates/matrix-sdk-crypto/src/identities/user.rs b/crates/matrix-sdk-crypto/src/identities/user.rs index 2e987236803..c117135aa5f 100644 --- a/crates/matrix-sdk-crypto/src/identities/user.rs +++ b/crates/matrix-sdk-crypto/src/identities/user.rs @@ -32,6 +32,8 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use tracing::{error, info}; +#[cfg(feature = "experimental-x509-identity-verification")] +use crate::x509::X509Verifier; use crate::{ CryptoStoreError, DeviceData, VerificationRequest, error::SignatureError, @@ -87,7 +89,18 @@ impl UserIdentity { Self::Own(OwnUserIdentity { inner: i, verification_machine, store }) } UserIdentityData::Other(i) => { - Self::Other(OtherUserIdentity { inner: i, own_identity, verification_machine }) + // X509Verifier holds an Arc so cloning it gives us a reference to the single + // underlying ClientCertVerifier + #[cfg(feature = "experimental-x509-identity-verification")] + let x509_verifier = store.x509_verifier().cloned(); + + Self::Other(OtherUserIdentity { + inner: i, + own_identity, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier, + verification_machine, + }) } } } @@ -335,6 +348,9 @@ pub struct OtherUserIdentity { pub(crate) inner: OtherUserIdentityData, pub(crate) own_identity: Option, pub(crate) verification_machine: VerificationMachine, + + #[cfg(feature = "experimental-x509-identity-verification")] + pub(crate) x509_verifier: Option, } impl Deref for OtherUserIdentity { @@ -352,11 +368,26 @@ impl DerefMut for OtherUserIdentity { } impl OtherUserIdentity { - /// Is this user identity verified. + /// Is this user identity verified? pub fn is_verified(&self) -> bool { - self.own_identity + if self + .own_identity .as_ref() .is_some_and(|own_identity| own_identity.is_identity_verified(&self.inner)) + { + return true; + } + + // If we have an X.509 verifier, we can use that to verify the user + #[cfg(feature = "experimental-x509-identity-verification")] + if let Some(x509_verifier) = &self.x509_verifier + && x509_verifier + .verify_signed_object(&self.user_id, self.inner.master_key.deref().as_ref()) + { + return true; + } + + false } /// Manually verify this user. @@ -1439,6 +1470,8 @@ pub(crate) mod tests { use assert_matches::assert_matches; use matrix_sdk_test::{async_test, test_json}; + #[cfg(feature = "experimental-x509-identity-verification")] + use rcgen::{Certificate, CertificateParams, DnType, Issuer, KeyPair}; use ruma::{TransactionId, device_id, user_id}; use serde_json::{Value, json}; use tokio::sync::Mutex; @@ -1468,7 +1501,10 @@ pub(crate) mod tests { store::Store, x509::{ RustRawX509Signer, RustRawX509Verifier, X509Signer, X509Verifier, - tests::cert_and_key_with_email_in_subject_distinguished_name, + tests::{ + cert_and_key_with_email_in_subject_distinguished_name, + subject_key_identifier_extension, + }, }, }; @@ -2079,14 +2115,129 @@ pub(crate) mod tests { assert!(x509_verifier.verify_signed_object(user_id!("@own_user:localhost"), &signed_key)); } + #[cfg(feature = "experimental-x509-identity-verification")] + #[async_test] + async fn test_verify_other_identity_with_x509() { + // Given that a CA exists + let (ca_cert, ca_signing_key) = ca_cert(); + + // And Alice has an X.509-signed identity + let alice_identity_data = signed_other_identity(&ca_cert, &ca_signing_key).await; + + // (And Bob exists) + let bob_account = Account::with_device_id(user_id!("@bob:hs.co"), device_id!("DEV123")); + let bob_verification_machine = get_verification_machine(&bob_account); + + let bob_identity_data = + bob_verification_machine.get_own_user_identity_data().await.unwrap(); + + // When Bob checks Alice's identity without using X.509 + let mut bobs_view_of_alice = OtherUserIdentity { + inner: alice_identity_data.clone(), + own_identity: Some(bob_identity_data), + verification_machine: bob_verification_machine.clone(), + x509_verifier: None, + }; + + // Then Alice is not verified + assert!(!bobs_view_of_alice.is_verified()); + + // But when Bob checks Alice's identity with a proper X.509 verifier + bobs_view_of_alice.x509_verifier = Some(X509Verifier::new(Arc::new( + RustRawX509Verifier::new_from_pem_data(&ca_cert.pem()).unwrap(), + ))); + + // Then Alice is verified + assert!(bobs_view_of_alice.is_verified()); + } + + /// Generate a key pair and cert, signed by the supplied certificate + /// authority, and return a new user's [`OtherUserIdentityData`] whose + /// master signing key is signed by them. + #[cfg(feature = "experimental-x509-identity-verification")] + async fn signed_other_identity( + ca_cert: &Certificate, + ca_signing_key: &KeyPair, + ) -> OtherUserIdentityData { + let (cert, signing_key) = + cert_and_key_with_email_signed_by("alice@hs.co", ca_cert, ca_signing_key); + + let x509_signer = X509Signer::new(Arc::new( + RustRawX509Signer::new_from_pem_data(&cert.pem(), &signing_key.serialize_pem()) + .unwrap(), + )); + + let account = Account::with_device_id(user_id!("@alice:hs.co"), device_id!("DEV123")); + + let private_identity = + PrivateCrossSigningIdentity::for_account(&account, Some(&x509_signer)).unwrap(); + + let public_identity = private_identity.to_public_identity().await.unwrap(); + + OtherUserIdentityData::new( + public_identity.master_key().clone(), + public_identity.self_signing_key().clone(), + ) + .unwrap() + } + + /// Generate a little certificate authority i.e. a key pair and a + /// self-signed certificate. + #[cfg(feature = "experimental-x509-identity-verification")] + fn ca_cert() -> (Certificate, KeyPair) { + let cert_params = cert_params("You Can Trust Us Certificate Authority"); + + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let cert = cert_params.self_signed(&signing_key).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Generate a key pair and a certificate signed by the supplied certificate + /// authority. + #[cfg(feature = "experimental-x509-identity-verification")] + fn cert_and_key_with_email_signed_by( + email: &str, + ca_cert: &Certificate, + ca_signing_key: &KeyPair, + ) -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let mut cert_params = cert_params(&format!("Cert for {email}")); + cert_params + .distinguished_name + .push(DnType::from_oid(crate::x509::tests::OID_PKCS9_EMAIL_ADDRESS), email); + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + + let issuer = Issuer::from_ca_cert_pem(&ca_cert.pem(), ca_signing_key) + .expect("Failed to create Issue from CA cert and key"); + + let cert = + cert_params.signed_by(&signing_key, &issuer).expect("Failed to generate certificate"); + + (cert, signing_key) + } + + /// Create a CertificateParams (for creating a certificate) where the + /// distinguished name has the CommonName provided. + #[cfg(feature = "experimental-x509-identity-verification")] + fn cert_params(common_name: &str) -> CertificateParams { + let mut cert_params = CertificateParams::default(); + cert_params.distinguished_name.remove(DnType::CommonName); + cert_params.distinguished_name.push(DnType::CommonName, common_name); + cert_params.use_authority_key_identifier_extension = true; + cert_params + } + /// Create an [`OtherUserIdentity`] for use in tests async fn other_user_identity() -> OtherUserIdentity { - use ruma::owned_device_id; - let other_user_identity_data = get_other_identity(); let account = - Account::with_device_id(user_id!("@own_user:localhost"), &owned_device_id!("DEV123")); + Account::with_device_id(user_id!("@own_user:localhost"), device_id!("DEV123")); let verification_machine = get_verification_machine(&account); let own_identity_data = verification_machine.get_own_user_identity_data().await.unwrap(); @@ -2095,6 +2246,8 @@ pub(crate) mod tests { inner: other_user_identity_data, own_identity: Some(own_identity_data), verification_machine, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: None, } } diff --git a/crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs b/crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs index 2b89dbba425..c2e626afe84 100644 --- a/crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs +++ b/crates/matrix-sdk-crypto/src/session_manager/group_sessions/share_strategy.rs @@ -1035,6 +1035,8 @@ fn has_identity_verification_violation( own_identity: Option<&OwnUserIdentityData>, device_owner_identity: Option<&UserIdentityData>, ) -> bool { + // TODO: AJB: share code with [`OtherUserIdentity::is_verified`] + device_owner_identity.is_some_and(|device_owner_identity| { device_owner_identity.was_previously_verified() && !is_user_verified(own_identity, device_owner_identity) From dcd95109d6b1de6b5faafc964142f1e8eb364724 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 11:01:02 +0100 Subject: [PATCH 11/13] feat(sdk): Wire X.509 signer and verifier through `ClientBuilder` Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- crates/matrix-sdk-base/Cargo.toml | 1 + crates/matrix-sdk-base/src/client.rs | 48 ++++++++++++++++++--- crates/matrix-sdk/Cargo.toml | 2 + crates/matrix-sdk/src/client/builder/mod.rs | 31 +++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk-base/Cargo.toml b/crates/matrix-sdk-base/Cargo.toml index 624e440eb51..5b49f84b87a 100644 --- a/crates/matrix-sdk-base/Cargo.toml +++ b/crates/matrix-sdk-base/Cargo.toml @@ -69,6 +69,7 @@ testing = [ unstable-msc4274 = [] experimental-element-recent-emojis = [] +experimental-x509-identity-verification = ["e2e-encryption", "matrix-sdk-crypto/experimental-x509-identity-verification"] [dependencies] as_variant.workspace = true diff --git a/crates/matrix-sdk-base/src/client.rs b/crates/matrix-sdk-base/src/client.rs index c6a326c4f6f..044a40aadc4 100644 --- a/crates/matrix-sdk-base/src/client.rs +++ b/crates/matrix-sdk-base/src/client.rs @@ -25,6 +25,8 @@ use eyeball::{SharedObservable, Subscriber}; use eyeball_im::{Vector, VectorDiff}; use futures_util::Stream; use matrix_sdk_common::{cross_process_lock::CrossProcessLockConfig, timer}; +#[cfg(feature = "experimental-x509-identity-verification")] +use matrix_sdk_crypto::x509::{X509Signer, X509Verifier}; #[cfg(feature = "e2e-encryption")] use matrix_sdk_crypto::{ CollectStrategy, DecryptionSettings, EncryptionSettings, OlmError, OlmMachine, @@ -135,6 +137,16 @@ pub struct BaseClient { /// Whether the client supports threads or not. pub threading_support: ThreadingSupport, + /// If supported, the signer that allows us to sign our cross-signing key + /// with an X.509 certificate. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: Option, + + /// If supported, the verifier that allows us to verify that items have been + /// signed by a valid X.509 certificate. + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: Option, + /// The definition of what is considered a DM room. pub dm_room_definition: DmRoomDefinition, } @@ -207,6 +219,10 @@ impl BaseClient { #[cfg(feature = "e2e-encryption")] handle_verification_events: true, threading_support, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: None, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: None, dm_room_definition, } } @@ -239,6 +255,10 @@ impl BaseClient { decryption_settings: self.decryption_settings.clone(), handle_verification_events, threading_support: self.threading_support, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: self.x509_signer.clone(), + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: self.x509_verifier.clone(), dm_room_definition: self.dm_room_definition.clone(), }; @@ -247,6 +267,20 @@ impl BaseClient { Ok(copy) } + /// Provide the signer we will use to sign master signing keys and outgoing + /// secret requests. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn set_x509_signer(&mut self, x509_signer: Option) { + self.x509_signer = x509_signer; + } + + /// Provide the verifier we will use to verify master signing keys and + /// incoming secret requests. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn set_x509_verifier(&mut self, x509_verifier: Option) { + self.x509_verifier = x509_verifier + } + /// Clones the current base client to use the same crypto store but a /// different, in-memory store config, and resets transient state. #[cfg(not(feature = "e2e-encryption"))] @@ -379,12 +413,16 @@ impl BaseClient { // Recreate the `OlmMachine` and wipe the in-memory cache in the store // because we suspect it has stale data. - let olm_machine = OlmMachineBuilder::new(&session_meta.user_id, &session_meta.device_id) + let builder = OlmMachineBuilder::new(&session_meta.user_id, &session_meta.device_id) .with_crypto_store(self.crypto_store.clone()) - .with_custom_account(custom_account) - .build() - .await - .map_err(OlmError::from)?; + .with_custom_account(custom_account); + + #[cfg(feature = "experimental-x509-identity-verification")] + let builder = builder + .with_x509_verifier(self.x509_verifier.clone()) + .with_x509_signer(self.x509_signer.clone()); + + let olm_machine = builder.build().await.map_err(OlmError::from)?; *self.olm_machine.write().await = Some(olm_machine); Ok(()) diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 3bd4e841fa3..6cb1a43f2c5 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -91,6 +91,8 @@ experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] +experimental-x509-identity-verification = ["e2e-encryption", "matrix-sdk-base/experimental-x509-identity-verification"] + [dependencies] anyhow = { workspace = true, optional = true } anymap2 = { version = "0.13.0", default-features = false } diff --git a/crates/matrix-sdk/src/client/builder/mod.rs b/crates/matrix-sdk/src/client/builder/mod.rs index 16e4c23a087..41fdeb5912b 100644 --- a/crates/matrix-sdk/src/client/builder/mod.rs +++ b/crates/matrix-sdk/src/client/builder/mod.rs @@ -32,6 +32,8 @@ use futures_util::try_join; use homeserver_config::*; #[cfg(feature = "e2e-encryption")] use matrix_sdk_base::crypto::DecryptionSettings; +#[cfg(feature = "experimental-x509-identity-verification")] +use matrix_sdk_base::crypto::x509::{X509Signer, X509Verifier}; #[cfg(feature = "e2e-encryption")] use matrix_sdk_base::crypto::{CollectStrategy, TrustRequirement}; use matrix_sdk_base::{ @@ -136,6 +138,10 @@ pub struct ClientBuilder { threading_support: ThreadingSupport, #[cfg(feature = "experimental-search")] search_index_store_kind: SearchIndexStoreKind, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: Option, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: Option, dm_room_definition: DmRoomDefinition, media_fetcher: Arc, } @@ -174,6 +180,10 @@ impl ClientBuilder { threading_support: ThreadingSupport::Disabled, #[cfg(feature = "experimental-search")] search_index_store_kind: SearchIndexStoreKind::InMemory, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_signer: None, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verifier: None, dm_room_definition: DmRoomDefinition::MatrixSpec, media_fetcher: Arc::new(DefaultMediaFetcher), } @@ -550,6 +560,22 @@ impl ClientBuilder { self } + /// The signer we will use to sign master signing keys and outgoing secret + /// requests. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn with_x509_signer(mut self, x509_signer: Option) -> Self { + self.x509_signer = x509_signer; + self + } + + /// The verifier we will use to verify master signing keys and incoming + /// secret requests. + #[cfg(feature = "experimental-x509-identity-verification")] + pub fn with_x509_verifier(mut self, x509_verifier: Option) -> Self { + self.x509_verifier = x509_verifier; + self + } + /// Create a [`Client`] with the options set on this builder. /// /// # Errors @@ -595,6 +621,11 @@ impl ClientBuilder { client.decryption_settings = self.decryption_settings; } + #[cfg(feature = "experimental-x509-identity-verification")] + client.set_x509_signer(self.x509_signer); + #[cfg(feature = "experimental-x509-identity-verification")] + client.set_x509_verifier(self.x509_verifier); + client }; From 27f74925524290df3ef7925ab7c7ab2ceaade342 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 11:01:02 +0100 Subject: [PATCH 12/13] feat(ffi): Expose X.509 signer and verifier over FFI Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- bindings/matrix-sdk-crypto-ffi/Cargo.toml | 1 + bindings/matrix-sdk-crypto-ffi/src/lib.rs | 3 +- bindings/matrix-sdk-crypto-ffi/src/machine.rs | 22 ++++--- bindings/matrix-sdk-ffi/Cargo.toml | 1 + bindings/matrix-sdk-ffi/src/client.rs | 26 ++++++++ bindings/matrix-sdk-ffi/src/client_builder.rs | 65 +++++++++++++++++++ xtask/src/kotlin.rs | 5 +- 7 files changed, 110 insertions(+), 13 deletions(-) diff --git a/bindings/matrix-sdk-crypto-ffi/Cargo.toml b/bindings/matrix-sdk-crypto-ffi/Cargo.toml index 477fcced7f9..87856c66cba 100644 --- a/bindings/matrix-sdk-crypto-ffi/Cargo.toml +++ b/bindings/matrix-sdk-crypto-ffi/Cargo.toml @@ -28,6 +28,7 @@ bundled-sqlite = ["matrix-sdk-sqlite/bundled"] experimental-encrypted-state-events = [ "matrix-sdk-crypto/experimental-encrypted-state-events", ] +experimental-x509-identity-verification = ["matrix-sdk-crypto/experimental-x509-identity-verification"] [dependencies] anyhow.workspace = true diff --git a/bindings/matrix-sdk-crypto-ffi/src/lib.rs b/bindings/matrix-sdk-crypto-ffi/src/lib.rs index 43d533ad38f..5d7a6d0c83c 100644 --- a/bindings/matrix-sdk-crypto-ffi/src/lib.rs +++ b/bindings/matrix-sdk-crypto-ffi/src/lib.rs @@ -28,8 +28,7 @@ pub use backup_recovery_key::{ }; pub use device::Device; pub use error::{ - BootstrapCrossSigningError, CryptoStoreError, DecryptionError, KeyImportError, - SecretImportError, SignatureError, + CryptoStoreError, DecryptionError, KeyImportError, SecretImportError, SignatureError, }; use js_int::UInt; pub use logger::{Logger, set_logger}; diff --git a/bindings/matrix-sdk-crypto-ffi/src/machine.rs b/bindings/matrix-sdk-crypto-ffi/src/machine.rs index bbf6a696942..e0483103e52 100644 --- a/bindings/matrix-sdk-crypto-ffi/src/machine.rs +++ b/bindings/matrix-sdk-crypto-ffi/src/machine.rs @@ -19,7 +19,7 @@ use matrix_sdk_crypto::{ decrypt_room_key_export, encrypt_room_key_export, olm::ExportedRoomKey, store::types::{BackupDecryptionKey, Changes}, - types::requests::ToDeviceRequest, + types::{Signature, requests::ToDeviceRequest}, }; use ruma::{ DeviceKeyAlgorithm, EventId, OneTimeKeyAlgorithm, OwnedTransactionId, OwnedUserId, RoomId, @@ -52,16 +52,15 @@ use tokio::runtime::Runtime; use zeroize::Zeroize; use crate::{ - BackupKeys, BackupRecoveryKey, BootstrapCrossSigningError, BootstrapCrossSigningResult, - CrossSigningKeyExport, CrossSigningStatus, DecodeError, DecryptedEvent, Device, DeviceLists, - EncryptionSettings, EventEncryptionAlgorithm, KeyImportError, KeysImportResult, - MegolmV1BackupKey, ProgressListener, Request, RequestType, RequestVerificationResult, - RoomKeyCounts, RoomSettings, Sas, SignatureUploadRequest, StartSasResult, UserIdentity, - Verification, VerificationRequest, + BackupKeys, BackupRecoveryKey, BootstrapCrossSigningResult, CrossSigningKeyExport, + CrossSigningStatus, DecodeError, DecryptedEvent, Device, DeviceLists, EncryptionSettings, + EventEncryptionAlgorithm, KeyImportError, KeysImportResult, MegolmV1BackupKey, + ProgressListener, Request, RequestType, RequestVerificationResult, RoomKeyCounts, RoomSettings, + Sas, SignatureUploadRequest, StartSasResult, UserIdentity, Verification, VerificationRequest, dehydrated_devices::DehydratedDevices, error::{ - CryptoStoreError, DecryptionError, SecretImportError, SecretsBundleExportError, - SignatureError, + BootstrapCrossSigningError, CryptoStoreError, DecryptionError, SecretImportError, + SecretsBundleExportError, SignatureError, }, parse_user_id, responses::{OwnedResponse, response_from_string}, @@ -1537,7 +1536,10 @@ impl OlmMachine { ( k.to_string(), match v { - Ok(s) => s.to_base64(), + Ok(s) => match s { + Signature::Ed25519(s) => s.to_base64(), + _ => panic!("Device key produced non-ed25519 signature"), + }, Err(i) => i.source, }, ) diff --git a/bindings/matrix-sdk-ffi/Cargo.toml b/bindings/matrix-sdk-ffi/Cargo.toml index 9df19a57e56..f718fabbab3 100644 --- a/bindings/matrix-sdk-ffi/Cargo.toml +++ b/bindings/matrix-sdk-ffi/Cargo.toml @@ -40,6 +40,7 @@ sentry = ["dep:sentry", "dep:sentry-tracing"] experimental-element-recent-emojis = ["matrix-sdk/experimental-element-recent-emojis"] experimental-push-secrets = ["matrix-sdk/experimental-push-secrets"] +experimental-x509-identity-verification = ["matrix-sdk-base/experimental-x509-identity-verification", "matrix-sdk/experimental-x509-identity-verification"] [dependencies] anyhow.workspace = true diff --git a/bindings/matrix-sdk-ffi/src/client.rs b/bindings/matrix-sdk-ffi/src/client.rs index f4829d6bef3..8aba0bc49fd 100644 --- a/bindings/matrix-sdk-ffi/src/client.rs +++ b/bindings/matrix-sdk-ffi/src/client.rs @@ -62,6 +62,8 @@ use matrix_sdk::{ sync::Notification, task_monitor::BackgroundTaskFailureReason, }; +#[cfg(feature = "experimental-x509-identity-verification")] +use matrix_sdk_base::crypto::x509::RawX509Signature; use matrix_sdk_common::{ SendOutsideWasm, SyncOutsideWasm, cross_process_lock::CrossProcessLockConfig, stream::StreamExt, }; @@ -316,6 +318,30 @@ pub trait SyncNotificationListener: SyncOutsideWasm + SendOutsideWasm { fn on_notification(&self, notification: NotificationItem, room_id: String); } +/// A foreign trait for low-level types which can sign messages using an +/// X.509-certified key pair. +#[cfg(feature = "experimental-x509-identity-verification")] +#[matrix_sdk_ffi_macros::export(with_foreign)] +pub trait X509Sign: SyncOutsideWasm + SendOutsideWasm + Debug { + /// Create a signature for the given message using our private key + /// + /// Returns (key ID, signature) + fn sign(&self, message: Vec) -> Result; +} + +/// A foreign trait for low-level types which can verify messages which were +/// signed using an X.509-certified key pair. +#[cfg(feature = "experimental-x509-identity-verification")] +#[matrix_sdk_ffi_macros::export(with_foreign)] +pub trait X509Verify: SyncOutsideWasm + SendOutsideWasm + Debug { + /// Check if the given signature is a valid X.509 signature for the given + /// message. + /// + /// Also validates that the certificate used for the signature is issued via + /// one of our trusted CAs. + fn verify(&self, message: Vec, sig: RawX509Signature) -> bool; +} + #[derive(Clone, Copy, uniffi::Record)] pub struct TransmissionProgress { pub current: u64, diff --git a/bindings/matrix-sdk-ffi/src/client_builder.rs b/bindings/matrix-sdk-ffi/src/client_builder.rs index 26d79340aa1..6c6df3899c8 100644 --- a/bindings/matrix-sdk-ffi/src/client_builder.rs +++ b/bindings/matrix-sdk-ffi/src/client_builder.rs @@ -19,6 +19,8 @@ use std::{fs, path::PathBuf}; use std::{num::NonZeroUsize, sync::Arc, time::Duration}; +#[cfg(feature = "experimental-x509-identity-verification")] +use matrix_sdk::encryption::SignatureError; #[cfg(not(any(target_family = "wasm")))] use matrix_sdk::reqwest::Certificate; #[cfg(feature = "experimental-search")] @@ -36,6 +38,8 @@ use matrix_sdk::{ VersionBuilderError, }, }; +#[cfg(feature = "experimental-x509-identity-verification")] +use matrix_sdk_base::crypto::x509::{RawX509Signature, X509Signer, X509Verifier}; use matrix_sdk_base::{ DmRoomDefinition, crypto::{CollectStrategy, DecryptionSettings, TrustRequirement}, @@ -44,6 +48,8 @@ use ruma::api::error::{DeserializationError, FromHttpResponseError}; use tracing::debug; use super::client::Client; +#[cfg(feature = "experimental-x509-identity-verification")] +use super::client::{X509Sign, X509Verify}; #[cfg(any(feature = "sqlite", feature = "indexeddb"))] use crate::store; use crate::{ @@ -163,6 +169,11 @@ pub struct ClientBuilder { threading_support: ThreadingSupport, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_sign: Option>, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verify: Option>, + dm_room_definition: DmRoomDefinition, media_fetcher: Option>, @@ -211,6 +222,12 @@ impl ClientBuilder { threading_support: ThreadingSupport::Disabled, #[cfg(feature = "experimental-search")] search_index_store: None, + + #[cfg(feature = "experimental-x509-identity-verification")] + x509_sign: None, + #[cfg(feature = "experimental-x509-identity-verification")] + x509_verify: None, + dm_room_definition: DmRoomDefinition::MatrixSpec, media_fetcher: None, }) @@ -544,12 +561,60 @@ impl ClientBuilder { inner_builder = inner_builder.media_fetcher(media_fetcher.clone()); } + #[cfg(feature = "experimental-x509-identity-verification")] + if let Some(x509_sign) = builder.x509_sign { + // Wrap the provided X509Sign impl in a shim which converts the arguments and + // results. + #[derive(Debug)] + struct X509SignImpl(Arc); + impl matrix_sdk_base::crypto::x509::RawX509Signer for X509SignImpl { + fn sign(&self, message: &[u8]) -> Result { + self.0 + .sign(message.to_vec()) + .map_err(|e| SignatureError::X509SigningError(e.to_string())) + } + } + + let x509_sign = Arc::new(X509SignImpl(x509_sign)); + inner_builder = inner_builder.with_x509_signer(Some(X509Signer::new(x509_sign))); + } + + #[cfg(feature = "experimental-x509-identity-verification")] + if let Some(x509_verify) = builder.x509_verify { + // Wrap the provided X509Verify impl in a shim which converts the arguments. + #[derive(Debug)] + struct X509VerifyImpl(Arc); + impl matrix_sdk_base::crypto::x509::RawX509Verifier for X509VerifyImpl { + fn verify(&self, message: &[u8], sig: &RawX509Signature) -> bool { + self.0.verify(message.to_vec(), sig.clone()) + } + } + let x509_verify = Arc::new(X509VerifyImpl(x509_verify)); + inner_builder = inner_builder.with_x509_verifier(Some(X509Verifier::new(x509_verify))); + } + let sdk_client = inner_builder.build().await?; Ok(Arc::new(Client::new(sdk_client, builder.session_delegate, store_path).await?)) } } +#[cfg(feature = "experimental-x509-identity-verification")] +#[matrix_sdk_ffi_macros::export] +impl ClientBuilder { + pub fn with_x509_sign(self: Arc, x509_sign: Arc) -> Arc { + let mut builder = unwrap_or_clone_arc(self); + builder.x509_sign = Some(x509_sign); + Arc::new(builder) + } + + pub fn with_x509_verify(self: Arc, x509_verify: Arc) -> Arc { + let mut builder = unwrap_or_clone_arc(self); + builder.x509_verify = Some(x509_verify); + Arc::new(builder) + } +} + #[cfg(feature = "sqlite")] #[matrix_sdk_ffi_macros::export] impl ClientBuilder { diff --git a/xtask/src/kotlin.rs b/xtask/src/kotlin.rs index 21d33edfcd7..a8d8410e892 100644 --- a/xtask/src/kotlin.rs +++ b/xtask/src/kotlin.rs @@ -22,7 +22,10 @@ impl Package { fn values(self) -> PackageValues { match self { Package::CryptoSDK => PackageValues { name: "matrix-sdk-crypto-ffi", features: "" }, - Package::FullSDK => PackageValues { name: "matrix-sdk-ffi", features: "sentry" }, + Package::FullSDK => PackageValues { + name: "matrix-sdk-ffi", + features: "sentry,experimental-x509-identity-verification", + }, } } } From 99bc3da5077246ed1a7d12c43cacc4d0ba4d8de9 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Mon, 6 Jul 2026 11:28:57 +0100 Subject: [PATCH 13/13] test(x509): End-to-end X.509 identity verification tests Co-Authored-By: Richard van der Hoff Co-Authored-By: Andy Balaam Co-Authored-By: Hubert Chathi Signed-off-by: Skye Elliot --- Cargo.lock | 4 + .../matrix-sdk-integration-testing/Cargo.toml | 8 +- .../src/helpers.rs | 21 +- .../src/tests/e2ee/mod.rs | 1 + .../src/tests/e2ee/x509.rs | 266 ++++++++++++++++++ 5 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 testing/matrix-sdk-integration-testing/src/tests/e2ee/x509.rs diff --git a/Cargo.lock b/Cargo.lock index 273e8b33d06..6d88c89860f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3803,12 +3803,16 @@ dependencies = [ "matrix-sdk", "matrix-sdk-base", "matrix-sdk-common", + "matrix-sdk-crypto", "matrix-sdk-test", "matrix-sdk-test-utils", "matrix-sdk-ui", + "oid-registry", "rand 0.10.1", + "rcgen", "reqwest", "serde_json", + "sha2", "similar-asserts", "stream_assert", "tempfile", diff --git a/testing/matrix-sdk-integration-testing/Cargo.toml b/testing/matrix-sdk-integration-testing/Cargo.toml index b7cdc08f3b8..519515df964 100644 --- a/testing/matrix-sdk-integration-testing/Cargo.toml +++ b/testing/matrix-sdk-integration-testing/Cargo.toml @@ -32,15 +32,19 @@ futures-core.workspace = true futures-util.workspace = true http.workspace = true json-structural-diff = "0.2.0" -matrix-sdk = { workspace = true, default-features = true, features = ["testing", "qrcode"] } -matrix-sdk-base = { workspace = true, default-features = true, features = ["testing", "qrcode"] } +matrix-sdk = { workspace = true, default-features = true, features = ["testing", "qrcode", "experimental-x509-identity-verification"] } +matrix-sdk-base = { workspace = true, default-features = true, features = ["testing", "qrcode", "experimental-x509-identity-verification"] } +matrix-sdk-crypto = { workspace = true, features = ["rust-x509-verifier-impl"] } matrix-sdk-common.workspace = true matrix-sdk-test.workspace = true matrix-sdk-test-utils.workspace = true matrix-sdk-ui = { workspace = true, default-features = true } +oid-registry = "0.8.1" rand.workspace = true +rcgen = { version = "0.14.8", features = ["aws_lc_rs", "x509-parser"] } reqwest.workspace = true serde_json.workspace = true +sha2.workspace = true similar-asserts.workspace = true stream_assert.workspace = true tempfile.workspace = true diff --git a/testing/matrix-sdk-integration-testing/src/helpers.rs b/testing/matrix-sdk-integration-testing/src/helpers.rs index 592c190b797..630c0914155 100644 --- a/testing/matrix-sdk-integration-testing/src/helpers.rs +++ b/testing/matrix-sdk-integration-testing/src/helpers.rs @@ -22,7 +22,10 @@ use matrix_sdk::{ sync::SyncResponse, timeout::ElapsedError, }; -use matrix_sdk_base::crypto::{CollectStrategy, DecryptionSettings, TrustRequirement}; +use matrix_sdk_base::crypto::{ + CollectStrategy, DecryptionSettings, TrustRequirement, + x509::{X509Signer, X509Verifier}, +}; use matrix_sdk_common::cross_process_lock::CrossProcessLockConfig; use rand::RngExt; use tempfile::{TempDir, tempdir}; @@ -47,6 +50,8 @@ pub struct TestClientBuilder { threading_support: ThreadingSupport, http_proxy: Option, cross_process_lock_config: CrossProcessLockConfig, + x509_signer: Option, + x509_verifier: Option, } impl TestClientBuilder { @@ -65,6 +70,8 @@ impl TestClientBuilder { room_key_recipient_strategy: Default::default(), enable_share_history_on_invite: false, threading_support: ThreadingSupport::Disabled, + x509_signer: None, + x509_verifier: None, http_proxy: None, cross_process_lock_config: CrossProcessLockConfig::SingleProcess, } @@ -100,6 +107,16 @@ impl TestClientBuilder { self } + pub fn x509_signer(mut self, x509_signer: Option) -> Self { + self.x509_signer = x509_signer; + self + } + + pub fn x509_verifier(mut self, x509_verifier: Option) -> Self { + self.x509_verifier = x509_verifier; + self + } + /// Simulate the behaviour of the clients when the "exclude insecure /// devices" (MSC4153) labs flag is enabled. pub fn exclude_insecure_devices(mut self, exclude_insecure_devices: bool) -> Self { @@ -135,6 +152,8 @@ impl TestClientBuilder { .with_room_key_recipient_strategy(self.room_key_recipient_strategy.clone()) .with_enable_share_history_on_invite(self.enable_share_history_on_invite) .with_threading_support(self.threading_support) + .with_x509_signer(self.x509_signer.clone()) + .with_x509_verifier(self.x509_verifier.clone()) .request_config(RequestConfig::short_retry()) .cross_process_store_config(self.cross_process_lock_config.clone()); diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs index d7a0b703502..608aa3dbc5c 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs @@ -45,6 +45,7 @@ use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; mod shared_history; #[cfg(feature = "experimental-encrypted-state-events")] mod state_events; +mod x509; // This test reproduces a bug seen on clients that use the same `Client` // instance for both the usual sliding sync loop and for getting the event for a diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/x509.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/x509.rs new file mode 100644 index 00000000000..70e5b10f114 --- /dev/null +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/x509.rs @@ -0,0 +1,266 @@ +use std::sync::Arc; + +use matrix_sdk::encryption::EncryptionSettings; +use matrix_sdk_crypto::x509::{RustRawX509Signer, RustRawX509Verifier, X509Signer, X509Verifier}; +use oid_registry::OID_PKCS9_EMAIL_ADDRESS; +use rand::RngExt as _; +use rcgen::{ + Certificate, CertificateParams, CustomExtension, DnType, Issuer, KeyPair, PublicKeyData, +}; +use tracing::Instrument as _; + +use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_user_is_not_verified_if_their_msk_is_not_signed() -> anyhow::Result<()> { + let alice_span = tracing::info_span!("alice"); + let bob_span = tracing::info_span!("bob"); + + // This is the base case: with no X.509 code running at all, users are + // unverified. + // + // Alice has no X.509 key pair. + // Alice registers on the server. They do not sign the MSK with any X.509 key. + let alice = create_encryption_enabled_client("alice", None, None) + .instrument(alice_span.clone()) + .await?; + + // Bob logs in to the server. + let bob = + create_encryption_enabled_client("bob", None, None).instrument(bob_span.clone()).await?; + + // Bob sees Alice as untrusted because her MSK is not signed by a valid X.509 + // key. + let bobs_view_of_alice = + bob.encryption().request_user_identity(alice.user_id().unwrap()).await?.unwrap(); + + assert!(!bobs_view_of_alice.is_verified()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_user_is_verified_if_their_msk_is_signed() -> anyhow::Result<()> { + let alice_span = tracing::info_span!("alice"); + let bob_span = tracing::info_span!("bob"); + + let (alice_username, alice_email) = username_and_email("alice"); + + // This is the happy path. + // + // A CA exists + let (ca_cert, ca_signing_key) = ca_cert(); + + // Alice has a valid X.509 key pair signed by the CA + let (alice_cert, alice_signing_key) = + cert_and_key_with_email_signed_by(&alice_email, &ca_cert, &ca_signing_key); + + // Alice can sign things using their key + let alice_x509_signer = X509Signer::new(Arc::new( + RustRawX509Signer::new_from_pem_data(&alice_cert.pem(), &alice_signing_key.serialize_pem()) + .unwrap(), + )); + + // Alice registers on the server. As part of creating their user identity, they + // sign their MSK and upload the signature to the server. + let alice = + create_encryption_enabled_client(&alice_username, Some(alice_x509_signer.clone()), None) + .instrument(alice_span.clone()) + .await?; + + // Bob can verify signatures that are properly linked to the CA + let (bob_username, _bob_email) = username_and_email("bob"); + let bob_x509_verifier = X509Verifier::new(Arc::new( + RustRawX509Verifier::new_from_pem_data(&ca_cert.pem()).unwrap(), + )); + + // Bob logs in to the server. + let bob = + create_encryption_enabled_client(&bob_username, None, Some(bob_x509_verifier.clone())) + .instrument(bob_span.clone()) + .await?; + + // Bob sees Alice as trusted because her MSK is signed by a valid X.509 key. + let bobs_view_of_alice = + bob.encryption().request_user_identity(alice.user_id().unwrap()).await?.unwrap(); + + assert!(bobs_view_of_alice.is_verified()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_user_is_not_verified_if_we_dont_trust_their_cert() -> anyhow::Result<()> { + let alice_span = tracing::info_span!("alice"); + let bob_span = tracing::info_span!("bob"); + + let (alice_username, alice_email) = username_and_email("alice"); + + // This is a failure case: Alice and Bob know about X.509, but they use a + // different CA, so Bob doesn't trust Alice's signature. + // + // 2 different CAs exist: one for Bob and one for Alice + let (alice_ca_cert, alice_ca_signing_key) = ca_cert(); + let (bob_ca_cert, _bob_ca_signing_key) = ca_cert(); + + // Alice has a valid X.509 key pair signed by their CA + let (alice_cert, alice_signing_key) = + cert_and_key_with_email_signed_by(&alice_email, &alice_ca_cert, &alice_ca_signing_key); + + // Alice can sign things using their key + let alice_x509_signer = X509Signer::new(Arc::new( + RustRawX509Signer::new_from_pem_data(&alice_cert.pem(), &alice_signing_key.serialize_pem()) + .unwrap(), + )); + + // Alice registers on the server. As part of creating their user identity, they + // sign their MSK and upload the signature to the server. + let alice = + create_encryption_enabled_client(&alice_username, Some(alice_x509_signer.clone()), None) + .instrument(alice_span.clone()) + .await?; + + // Bob can verify signatures that are properly linked to their CA + let (bob_username, _bob_email) = username_and_email("bob"); + let bob_x509_verifier = X509Verifier::new(Arc::new( + RustRawX509Verifier::new_from_pem_data(&bob_ca_cert.pem()).unwrap(), + )); + + // Bob logs in to the server. + let bob = + create_encryption_enabled_client(&bob_username, None, Some(bob_x509_verifier.clone())) + .instrument(bob_span.clone()) + .await?; + + // Bob sees Alice as unverified because even though her MSK is signed by an + // X.509 key, that key is not trusted because it is signed by a CA that Bob + // does not know. + let bobs_view_of_alice = + bob.encryption().request_user_identity(alice.user_id().unwrap()).await?.unwrap(); + + assert!(!bobs_view_of_alice.is_verified()); + + Ok(()) +} + +/// Creates a new encryption-enabled client with the given username and +/// settings. +/// +/// # Arguments +/// +/// * `username` - The username for the client. +/// * `exclude_insecure_devices` - A boolean indicating whether to exclude +/// insecure devices. +async fn create_encryption_enabled_client( + username: &str, + x509_signer: Option, + x509_verifier: Option, +) -> anyhow::Result { + let encryption_settings = + EncryptionSettings { auto_enable_cross_signing: true, ..Default::default() }; + + let client = SyncTokenAwareClient::new( + TestClientBuilder::with_exact_username(username.to_owned()) + .use_sqlite() + .encryption_settings(encryption_settings) + .x509_signer(x509_signer) + .x509_verifier(x509_verifier) + .build() + .await?, + ); + + client.encryption().wait_for_e2ee_initialization_tasks().await; + Ok(client) +} + +/// Generate a little certificate authority i.e. a key pair and a +/// self-signed certificate. +fn ca_cert() -> (Certificate, KeyPair) { + let cert_params = cert_params("Delboy Inc Trust Us Certificate Authority"); + + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + (cert_params.self_signed(&signing_key).expect("Failed to generate certificate"), signing_key) +} + +/// Create a certificate that contains the supplied email address in its +/// Subject Distinguished Name, and is signed by the supplied certificate +/// authority. +/// +/// Note: https://www.rfc-editor.org/rfc/rfc5280.html#section-4.1.2.6 says: +/// +/// > Legacy implementations exist where an electronic mail address is +/// > embedded in the subject distinguished name as an emailAddress +/// > attribute [RFC2985]. +/// +/// So this is a legacy implementation. To be non-legacy it should, at a +/// minimum, include the email address in the Subject Alternative Name +/// as well as in Subject Distinguished Name. +fn cert_and_key_with_email_signed_by( + email: &str, + ca_cert: &Certificate, + ca_signing_key: &KeyPair, +) -> (Certificate, KeyPair) { + let signing_key = + KeyPair::generate_for(&rcgen::PKCS_RSA_SHA512).expect("Failed to generate key pair"); + + let mut cert_params = cert_params(&format!("A nice cert for {email}")); + let email_address_oid = OID_PKCS9_EMAIL_ADDRESS.iter().unwrap().collect(); + cert_params.distinguished_name.push(DnType::CustomDnType(email_address_oid), email); + cert_params.custom_extensions.push(subject_key_identifier_extension(&signing_key)); + + let issuer = Issuer::from_ca_cert_pem(&ca_cert.pem(), ca_signing_key) + .expect("Failed to create an issuer for the CA"); + + let cert = + cert_params.signed_by(&signing_key, &issuer).expect("Failed to generate certificate"); + + (cert, signing_key) +} + +/// Create a CertificateParams (for creating a certificate) where the +/// distinguished name has the CommonName provided. +fn cert_params(common_name: &str) -> CertificateParams { + let mut cert_params = CertificateParams::default(); + cert_params.distinguished_name.remove(DnType::CommonName); + cert_params.distinguished_name.push(DnType::CommonName, common_name); + cert_params.use_authority_key_identifier_extension = true; + cert_params +} + +fn username_and_email(prefix: &str) -> (String, String) { + let suffix: u128 = rand::rng().random(); + + let username = format!("{prefix}{suffix}"); + // N.B: CI uses `synapse` as server name, which we must match. This + // will error the local integration test setup in `ci-start.sh`. + let email = format!("{username}@synapse"); + + (username, email) +} + +/// Build an X.509 "SubjectKeyIdentifier" extension for a cert with the +/// given public key. +pub(crate) fn subject_key_identifier_extension( + signing_key: &impl PublicKeyData, +) -> CustomExtension { + // Ref https://www.rfc-editor.org/info/rfc5280/#section-4.2.1.2 + + use sha2::{Digest, Sha256}; + + // The actual bytes in the SKI don't actually matter that much (and the RFC just + // makes a couple of suggestions): they just need to be a reasonably + // unique way of referring to the certificate with the right public key. + let spki = signing_key.subject_public_key_info(); + let spki_hash = Sha256::digest(&spki); + let ski_bytes = &spki_hash.as_slice()[0..20]; + + // Hacky encoding of the bytes as a DER OCTET-STRING + let ski_der = [&[0x04, ski_bytes.len() as u8], ski_bytes].concat(); + + CustomExtension::from_oid_content( + &[2, 5, 29, 14], // subjectKeyIdentifier + ski_der, + ) +}