pubhubs/api/sso.rs
1//! Data structures related to the authentication of users towards hubs.
2//!
3//! Below is an overview of the single-sign-on flow, including the most important (cryptographic)
4//! details. Remaining details can be found in the code itself.
5//!
6//! # Matrix IDs
7//!
8//! Each user $U$ gets assigned a random **matrix id (mxid)** when they enter a hub $H$
9//! of the form
10//! $$a_1a_2\dotsb a_n c\text{-}d b_1b_2\dotsb b_n,$$ where $n\in \\{2,\dotsc,15\\}$
11//! is large enough for the matrix ID to be unique,
12//! $a_1,\dotsc,a_n,b_1,\dotsc, b_n$ are random hex digits, and $c,d\in \\{0,1,\dotsc,f,g\\}$
13//! are [ISBN10-style check digits](https://en.wikipedia.org/wiki/ISBN#ISBN-10_check_digits)
14//! (but using modulo 17 instead of modulo 11)
15//! over $a_1\dotsb a_n$ and $b_1\dotsb b_n$, respectively.
16//!
17//! This mxid is *not* derived from the 'pubhubs' pseudonym a hub $H$ receives for the user $U$
18//! from pubhubs via the single-sign-on flow below. Instead, the (pubhubs) pseudonym of a user
19//! is linked to its (random) mxid as 'external user id' via the
20//! [`record_user_external_id`](https://github.com/element-hq/synapse/blob/7b1c4da5dfbe48fa5e0dabc3933aed54f40e1644/synapse/module_api/__init__.py#L935)
21//! function. This is done for two reasons:
22//!
23//! 1. The pubhubs pseudonym for a user is seen by PHC, and so PHC could track a user across hubs
24//! if hubs would use the pubhubs pseudonym as mxid.
25//!
26//! 2. Decoupling the mxid from the pubhubs pseudonym allows for changes to the pseudonym system in
27//! the future without users having to change their mxid.
28//!
29//! # Pseudonyms
30//!
31//! The pseudonym the hub $H$ gets for a user $U$ (and uses as 'external id') is
32//! $$\mathrm{Sha512}(g_H \cdot \mathrm{Id}\_U )$$
33//! mapped to a [`CurvePoint`], where:
34//!
35//! - $\mathrm{Id}\_U$ is a permanent unchanging **secret user identifier**
36//! (a random [`CurvePoint`]). The secret user identifier is known to no one,
37//! not even the user itself. PHC and the transcryptor only get to see the ElGamal encrypted
38//! form known as the _polymorphic pseudonym_ (see below).
39//!
40//! > **Note:** The secret user identifier should not be confused with the `user_id`
41//! > used by PHC internally to identify a user.
42//!
43//!
44//! - $g_H$ is the **pseudonymisation factor**, a [`scalar`](Scalar) unique
45//! to the hub $H$, known only by the transcryptor:
46//! $$g_H := \mathrm{Sha512}(H \Vert \ell_d \Vert d \Vert \ell_g \Vert g)$$
47//! where $H$ is the [**hub id**](crate::hub::BasicInfo::id) (32 bytes),
48//! $d := \text{"pubhubs-pseud-factor"}$, $g$ is the transcryptor's
49//! pseudonymisation-factor secret, and $\ell_d, \ell_g$ are the byte
50//! lengths of $d, g$ encoded as 8-byte big-endian unsigned integers.
51//!
52//! <details class="toggle">
53//! <summary class="hideme"><span>Expand example </span></summary>
54//!
55//! **Example**
56//! ```
57//! use sha2::Digest; // brings `chain_update` and `new` into scope
58//! let h = pubhubs::id::Id::from([7u8; 32]);
59//! let g: &[u8] = b"abc"; // 3 bytes
60//! let d = "pubhubs-pseud-factor"; // 20 bytes
61//! assert_eq!(
62//! pubhubs::phcrypto::pseud_factor_for_hub(g, h),
63//! curve25519_dalek::Scalar::from_hash(
64//! sha2::Sha512::new()
65//! .chain_update(h.as_slice())
66//! .chain_update([0, 0, 0, 0, 0, 0, 0, 20u8])
67//! .chain_update(d.as_bytes())
68//! .chain_update([0, 0, 0, 0, 0, 0, 0, 3u8])
69//! .chain_update(g),
70//! ),
71//! );
72//! ```
73//!
74//! </details>
75//!
76//! > **Note:** The white paper uses $g_H\cdot \mathrm{Id}\_U$ instead
77//! > of $\mathrm{Sha512}(g_H\cdot \mathrm{Id}\_U)$. The hash has been added to
78//! > protect the pseudonym against harvest-now-decrypt-later-by-a-quantum-computer attacks.
79//!
80//! The SSO flow described below gets the pseudonym
81//! $\mathrm{Sha512}(g_H \cdot \mathrm{Id}\_U )$ of a user $U$ to the hub $H$ in such a way that:
82//! - The hub learns only this pseudonym.
83//! - PHC learns $U$, but not what hub $H$ they are visiting.
84//! - The transcryptor learns $H$ (and knows $g_H$), but can not (without a quantum computer) deduce $U$.
85//!
86//! The assumption here is, of course, that PHC and the transcryptor do not collude.
87//!
88//! # Polymorphic pseudonyms
89//!
90//! A **polymorphic pseudonym** $\mathrm{PP}\_U$
91//! for the user $U$ is an [ElGamal encryption](elgamal::Triple) of
92//! $\mathrm{Id}\_U$ of the form
93//! $$ \mathrm{PP}\_U \ :=\ (rB,\ \mathrm{Id}\_U + rxB,\ xB).$$
94//! Here:
95//! - $B$ denotes the base point used by [`CurvePoint`].
96//! - $x := x\_{\mathrm{T}} x\_{\mathrm{PHC}}$ is the **master encryption key**,
97//! that splits into two **master encryption key parts**, $x\_{\mathrm{T}}$ and $x\_{\mathrm{PHC}}$,
98//! picked by the transcryptor and PHC, respectively.
99//! - $r$ is a random [`Scalar`].
100//!
101//! While each user has just one $\mathrm{Id}\_U$,
102//! it has many different polymorphic pseudonyms on account of the random factor $r$.
103//! The polymorphic pseudonym serves two purposes:
104//!
105//! 1. It 'identifies' the user $U$ (towards the transcryptor) without always
106//! having the same shape, so it can not be used to track logins of the same user.
107//!
108//! 2. It allows operations to be performed on $\mathrm{Id}\_U$ without revealing $\mathrm{Id}\_U$
109//! itself (via the _homomorphic_ properties of ElGamal encryption, see [`elgamal::Triple::rsk`].)
110//!
111//! # Flow
112//!
113//! 1. The global client first obtains from PHC via [`phc::user::PppEP`] a
114//! [`PolymorphicPseudonymPackage`] (**PPP**), sealed for the transcryptor, which contains
115//! a freshly rerandomized polymorphic pseudonym, $\mathrm{PP}\_U$, and a
116//! polymorphic pseudonym nonce (**phc nonce**). The phc nonce is essentially an encrypted cookie
117//! that contains the `user_id` and expiry of the PP.
118//!
119//! Simultaneously, the global client obtains a **hub state** and **hub nonce** from the hub via the
120//! [`hub::EnterStartEP`] endpoint. The hub state and hub nonce are encrypted cookies too.
121//! The hub state contains an issued-at timestamp, and both the hub state and the hub
122//! nonce contain the same random identifier (linking them together).
123//!
124//! 2. The global client sends the PPP, hub nonce and $H$ to the [`tr::EhppEP`] endpoint.
125//! The transcryptor extracts $\mathrm{PP}\_U$, and
126//! (using [`crate::phcrypto::t_encrypted_hub_pseudonym`])
127//!
128//! 1. multiplies the underlying plaintext with $g_H$,
129//! 2. rekeys it, removing the $x\_\mathrm{T}$-component, and
130//! 3. rerandomizes it.
131//!
132//! The resulting **encrypted hub pseudonym** should be an ElGamal encryption of
133//! $g_H \mathrm{Id}\_U$ keyed for $x\_\mathrm{PHC}$.
134//!
135//! This encrypted hub pseudonym is bundled together with the hub nonce and phc nonce
136//! in an [`EncryptedHubPseudonymPackage`] (**EHPP**),
137//! and returned by the transcryptor to the global client sealed for PHC.
138//!
139//! 3. The global client forwards the EHPP back to PHC, via [`phc::user::HhppEP`].
140//! PHC extracts the phc nonce, checks its validity, and extracts the `user_id` from it,
141//! and checks that it coincides with the `user_id` from the auth token.
142//! If this checks out, PHC proceeds by decrypting the encrypted hub pseudonym using
143//! $x\_\mathrm{PHC}$, yielding $g_H \mathrm{Id}\_U$.
144//! PHC then computes the **hashed hub pseudonym** $\mathrm{Sha512}(g_H\cdot\mathrm{Id}\_U)$,
145//! and returns it to global client in a signed [`HashedHubPseudonymPackage`] (**HHPP**) that also contains
146//! the hub nonce and the timestamp at which the polymorphic pseudonym was created.
147//!
148//! 4. The global client forwards this HHPP to the hub, via the [`hub::EnterCompleteEP`],
149//! including also the hub state. The hub checks the signature on the HHPP, whether
150//! the hub nonce (from the HHPP) and the hub state (sent alongside) are genuine and belong
151//! to one another, and
152//! whether the PP and hub state are fresh (issued no longer than 10 seconds ago).
153//! If everything checks out, the hashed hub pseudonym is used by the hub as external user
154//! id to look up the (or register a) matrix user for $U$ at $H$.
155
156use crate::api::*;
157
158use serde::{Deserialize, Serialize};
159
160use crate::common::elgamal;
161use crate::id;
162use crate::misc::jwt;
163
164/// Returned (in sealed form) by [`phc::user::PppEP`], needed for [`tr::EhppEP`].
165#[derive(Serialize, Deserialize, Debug, Clone)]
166#[serde(deny_unknown_fields)]
167pub struct PolymorphicPseudonymPackage {
168 /// The actual polymorphic pseudonym for the user
169 pub polymorphic_pseudonym: elgamal::Triple,
170
171 pub nonce: phc::user::PpNonce,
172}
173
174having_message_code!(PolymorphicPseudonymPackage, Ppp);
175
176/// Returned (in sealed form) by [`tr::EhppEP`], needed for [`phc::user::HhppEP`].
177///
178/// NB: travels inside [`Sealed`], which uses postcard (positional, no field names): field order
179/// and presence ARE the wire format — `serde(default)`, `skip_serializing_if` and
180/// `deny_unknown_fields` all have no effect here.
181#[derive(Serialize, Deserialize, Debug, Clone)]
182pub struct EncryptedHubPseudonymPackage {
183 /// Hub pseudonym `g_H Id_U`, elgamal encrypted for `x_PHC`.
184 pub encrypted_hub_pseudonym: elgamal::Triple,
185
186 /// Nonce, from [`hub::EnterStartEP`]
187 pub hub_nonce: hub::EnterNonce,
188
189 /// Nonce, from [`PolymorphicPseudonymPackage::nonce`]
190 pub phc_nonce: phc::user::PpNonce,
191
192 /// HMAC binding the hub id the transcryptor pseudonymised for; see [`hub::HubMacKey::mac`].
193 /// `None` when [`EhppReq::hub_mac_key`](tr::EhppReq::hub_mac_key) was absent.
194 pub hub_id_mac: Option<id::Id>,
195}
196
197having_message_code!(EncryptedHubPseudonymPackage, Ehpp);
198
199/// Returned (signed) by [`phc::user::HhppEP`], needed for [`hub::EnterCompleteEP`].
200#[derive(Serialize, Deserialize, Debug, Clone)]
201#[serde(deny_unknown_fields)]
202pub struct HashedHubPseudonymPackage {
203 /// The hashed hub pseudonym, hashed to a point on curve25519 so we can decide to add an
204 /// additional layer of ElGamal encryption later on.
205 pub hashed_hub_pseudonym: CurvePoint,
206
207 /// When the original pseudonym was issued
208 pub pp_issued_at: jwt::NumericDate,
209
210 /// Nonce, from [`hub::EnterStartEP`]
211 pub hub_nonce: hub::EnterNonce,
212
213 /// HMAC binding the hub id, copied through from [`EncryptedHubPseudonymPackage::hub_id_mac`]; see
214 /// [`hub::HubMacKey::mac`].
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub hub_id_mac: Option<id::Id>,
217}
218
219impl Signable for HashedHubPseudonymPackage {
220 const CODE: MessageCode = MessageCode::Hhpp;
221 const CONSTELLATION_BOUND: bool = true;
222}
223
224/// Which signature scheme the hub expects on its [`HashedHubPseudonymPackage`] (HHPP), and thus
225/// which key PHC signs it with.
226///
227/// Hubs predating the hybrid post-quantum migration verify a classical ed25519 (EdDSA) signature
228/// against the constellation's `phc_jwt_key`; newer hubs verify the hybrid composite signature
229/// against `phc_verifying_key`. The hub advertises its choice via [`hub::EnterStartResp`], the
230/// global client relays it to [`phc::user::HhppEP`], and PHC signs accordingly.
231///
232/// Absent on the wire ⇒ [`Ed25519`](Self::Ed25519), so hubs and global clients predating this field
233/// keep getting a signature they can verify. Each variant's wire value is the JWS `alg` the HHPP
234/// will carry.
235#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum HhppSignatureScheme {
237 /// Classical ed25519 (EdDSA), verifiable by pre-hybrid hubs. The default.
238 #[default]
239 #[serde(rename = "EdDSA")]
240 Ed25519,
241
242 /// PubHubs' interim, non-conformant hybrid composite (empty ML-DSA context) — the only hybrid
243 /// PHC currently signs with. Its wire value matches [`crate::common::dsa::ALG`].
244 #[serde(rename = "ph-ML-DSA-65-Ed25519")]
245 HybridInterim,
246
247 /// The conformant LAMPS-standard composite. **Not yet implemented**: PHC rejects requests for
248 /// it until [`aws_lc_rs`] exposes an ML-DSA context (see [`crate::common::dsa`]). Reserved here
249 /// so the wire protocol already carries its identifier.
250 #[serde(rename = "ML-DSA-65-Ed25519")]
251 HybridStandard,
252}
253
254impl HhppSignatureScheme {
255 /// `true` for the default ([`Ed25519`](Self::Ed25519)). Used with `skip_serializing_if` to omit
256 /// the field from the wire when it is the default, so peers predating it — which
257 /// `deny_unknown_fields` — still accept the message (e.g. a PHC that hasn't learned the field).
258 pub fn is_default(&self) -> bool {
259 matches!(self, Self::Ed25519)
260 }
261}