pubhubs/servers/constellation.rs
1//! Details on the constellation of PubHubs servers
2
3use std::ops::Deref;
4
5use sha2::digest::Digest;
6
7use crate::api;
8use crate::common::{kem, secret};
9use crate::id;
10use crate::phcrypto;
11use crate::servers;
12
13/// Public details on the constellation of PubHubs servers (stored in the [`inner`] field)
14/// paired with an derived [`id`]. [`Deref`]s to [`Inner`].
15///
16/// # Comparing constellations
17///
18/// [`Constellation`] does not implement [`PartialEq`], because there are two valid ways to compare
19/// constellations `c1` and `c2`, namely `c1.id == c2.id` and `c1.inner == c2.inner`, and it should
20/// be clear in the code which one is being used.
21///
22/// [`id`]: Constellation::id
23/// [`inner`]: Constellation::inner
24#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
25pub struct Constellation {
26 /// Identifier for this constellation derived from [`Inner`] using a hash.
27 pub id: id::Id,
28
29 /// When this constellation was first created by pubhubs central. When two parties
30 /// have different constellations, the party with the oldest constellation should
31 /// update.
32 pub created_at: api::NumericDate,
33
34 #[serde(flatten)]
35 pub inner: Inner,
36}
37
38impl Deref for Constellation {
39 type Target = Inner;
40
41 fn deref(&self) -> &Inner {
42 &self.inner
43 }
44}
45// NOTE: When adding a new field to the constellation make sure it has a default value in the first
46// version so that when the new version of PHC contacts the old versions of the transcryptor and
47// the authentication server, PHC will not crash on missing fields at the transcryptor and the
48// authentication server.
49//
50// (The converse is not necessary: when an outdated authentication server and transcryptor
51// are running discovery against a freshly updated PHC, PHC's constellation will not be set
52// and will thus not cause the transcryptor or authentication server to crash.)
53#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
54pub struct Inner {
55 pub transcryptor_url: url::Url,
56
57 /// The transcryptor's hybrid post-quantum verifying key, used to verify its JWTs and signatures.
58 pub transcryptor_verifying_key: api::VerifyingKeyBytes,
59
60 /// Hash of the transcryptor's master encryption key part `x_T B`, so the transcryptor can check
61 /// that the correct keypart was used without `x_T B` being exposed in the clear.
62 pub transcryptor_master_enc_key_part_hash: id::Id,
63
64 /// [`kem::EncapKeyBytes::id`] of the transcryptor's encapsulation key.
65 pub transcryptor_encap_key_id: id::Id,
66
67 /// Shared secret PHC encapsulated against the transcryptor's encap key.
68 pub transcryptor_ss_encap: kem::CiphertextBytes,
69
70 pub phc_url: url::Url,
71
72 /// PHC's ed25519 public key (the `ed` half of [`phc_verifying_key`](Self::phc_verifying_key)),
73 /// hex-encoded. Kept on the wire so hubs predating the hybrid migration can verify the classical
74 /// EdDSA HHPP; see [`api::Ed25519VerifyingKeyHex`].
75 ///
76 /// TODO: remove once all hubs are on >=v3.4.0 (see scripts/check-hubs.py).
77 #[serde(default)]
78 pub phc_jwt_key: api::Ed25519VerifyingKeyHex,
79
80 /// PHC's hybrid post-quantum verifying key, used to verify its JWTs and signatures.
81 pub phc_verifying_key: api::VerifyingKeyBytes,
82
83 /// Hash of PHC's master encryption key part `x_PHC B`. Published so that a change of PHC's
84 /// part churns the constellation id (the real master key is held off-wire by PHC).
85 pub phc_master_enc_key_part_hash: id::Id,
86
87 pub auths_url: url::Url,
88
89 /// The authentication server's hybrid post-quantum verifying key, used to verify its JWTs and
90 /// signatures.
91 pub auths_verifying_key: api::VerifyingKeyBytes,
92
93 /// [`kem::EncapKeyBytes::id`] of the authentication server's encapsulation key.
94 pub auths_encap_key_id: id::Id,
95
96 /// Shared secret PHC encapsulated against the authentication server's encap key.
97 pub auths_ss_encap: kem::CiphertextBytes,
98
99 pub global_client_url: url::Url,
100
101 /// pubhubs version
102 pub ph_version: Option<String>,
103}
104
105/// Extension methods on [`sha2::Sha256`] used by [`Inner::sha256`] to give the constellation an
106/// unambiguous byte encoding before hashing.
107trait DigestExt: Sized {
108 /// Length-prefix (8-byte big-endian, platform-independent) a variable-length field.
109 fn chain_varlen(self, bytes: &[u8]) -> Self;
110
111 /// A 1/0 presence byte, followed by the length-prefixed bytes when present.
112 fn chain_opt(self, bytes: Option<&[u8]>) -> Self;
113
114 /// Both length-prefixed ciphertext halves (ML-KEM ‖ EC).
115 fn chain_ct(self, ct: &kem::CiphertextBytes) -> Self;
116
117 /// Both length-prefixed halves (ed25519 ‖ ML-DSA) of a hybrid verifying key.
118 fn chain_vk(self, vk: &api::VerifyingKeyBytes) -> Self;
119}
120
121impl DigestExt for sha2::Sha256 {
122 fn chain_varlen(self, bytes: &[u8]) -> Self {
123 self.chain_update(secret::encode_usize(bytes.len()))
124 .chain_update(bytes)
125 }
126
127 fn chain_opt(self, bytes: Option<&[u8]>) -> Self {
128 match bytes {
129 Some(bytes) => self.chain_update([1u8]).chain_varlen(bytes),
130 None => self.chain_update([0u8]),
131 }
132 }
133
134 fn chain_ct(self, ct: &kem::CiphertextBytes) -> Self {
135 self.chain_varlen(ct.ml.as_ref())
136 .chain_varlen(ct.ec.as_ref())
137 }
138
139 fn chain_vk(self, vk: &api::VerifyingKeyBytes) -> Self {
140 self.chain_varlen(vk.ed.as_ref())
141 .chain_varlen(vk.ml.as_ref())
142 }
143}
144
145impl Inner {
146 /// Returns the url of the named server
147 pub fn url(&self, name: servers::Name) -> &url::Url {
148 match name {
149 servers::Name::PubhubsCentral => &self.phc_url,
150 servers::Name::Transcryptor => &self.transcryptor_url,
151 servers::Name::AuthenticationServer => &self.auths_url,
152 }
153 }
154
155 /// Returns a [`sha2::Sha256`] hash of this constellation - used to compute [`Constellation::id`].
156 pub(crate) fn sha256(&self) -> sha2::Sha256 {
157 let Inner {
158 transcryptor_url,
159 transcryptor_verifying_key,
160 transcryptor_master_enc_key_part_hash,
161 transcryptor_encap_key_id,
162 transcryptor_ss_encap,
163
164 phc_url,
165 phc_verifying_key,
166 phc_master_enc_key_part_hash,
167
168 auths_url,
169 auths_verifying_key,
170 auths_encap_key_id,
171 auths_ss_encap,
172
173 global_client_url,
174
175 ph_version,
176
177 // not hashed: this is the `ed` half of `phc_verifying_key`, already covered above.
178 phc_jwt_key: _,
179 } = self;
180
181 // NOTE: it would be easier to serialize self using, say, serde_json, and then hash that,
182 // but it's not evident whether serializing the same constellation twice will give the same
183 // string.
184 //
185 // Framing (see `DigestExt`): fixed-length fields (32-byte id hashes) are hashed directly;
186 // variable-length fields, and the two halves of each hybrid verifying key / KEM ciphertext,
187 // are length-prefixed (`chain_varlen`/`chain_vk`/`chain_ct`) so one field's bytes can't be
188 // read as part of an adjacent one. The only optional field is `ph_version` (`chain_opt`, a
189 // 1/0 presence byte).
190
191 sha2::Sha256::new()
192 // Hash-format version - BUMP THIS on any change to the framing or fields below, so the
193 // change always alters the constellation id. (Only PHC computes the id; peers compare.)
194 // v2: jwt keys became hybrid post-quantum (ed25519 ‖ ML-DSA).
195 // v3: dropped the deprecated enc_key / master_enc_key / `*_jwt_key` placeholder fields,
196 // and the verifying-key / KEM / master-key-part-hash fields are no longer optional.
197 .chain_update(3u16.to_be_bytes())
198 .chain_varlen(transcryptor_url.as_str().as_bytes())
199 .chain_vk(transcryptor_verifying_key)
200 .chain_update(transcryptor_master_enc_key_part_hash.as_slice())
201 .chain_update(transcryptor_encap_key_id.as_slice())
202 .chain_ct(transcryptor_ss_encap)
203 .chain_varlen(phc_url.as_str().as_bytes())
204 .chain_vk(phc_verifying_key)
205 .chain_update(phc_master_enc_key_part_hash.as_slice())
206 .chain_varlen(auths_url.as_str().as_bytes())
207 .chain_vk(auths_verifying_key)
208 .chain_update(auths_encap_key_id.as_slice())
209 .chain_ct(auths_ss_encap)
210 .chain_varlen(global_client_url.as_str().as_bytes())
211 .chain_opt(ph_version.as_ref().map(|v| v.as_bytes()))
212 }
213
214 pub fn derive_id(&self) -> id::Id {
215 phcrypto::constellation_id(self)
216 }
217}
218
219/// A full [`Constellation`], or just the [`id::Id`].
220#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
221#[serde(untagged)]
222pub enum ConstellationOrId {
223 Constellation(Box<Constellation>),
224 Id { id: id::Id },
225}
226
227impl ConstellationOrId {
228 /// Returns the [`Constellation`], if any.
229 pub fn constellation(&self) -> Option<&Constellation> {
230 if let Self::Constellation(c) = self {
231 return Some(c);
232 }
233 None
234 }
235
236 /// Returns underlying [`Constellation`], if any.
237 pub fn into_constellation(self) -> Option<Constellation> {
238 if let Self::Constellation(c) = self {
239 return Some(*c);
240 }
241 None
242 }
243
244 /// Returns the [`id::Id`]
245 pub fn id(&self) -> &id::Id {
246 match self {
247 Self::Constellation(c) => &c.id,
248 Self::Id { id } => id,
249 }
250 }
251}