Skip to main content

paykit_lib/
noise_key_authorization.rs

1//! Identity-signed authorization of the current Paykit Noise key.
2
3use base64::{engine::general_purpose::STANDARD, Engine};
4use serde::{Deserialize, Serialize};
5
6use crate::{pubky_routing, validation::invalid_data, PaykitError, PublicKey, Result};
7
8const KIND: &str = "paykit.noise_key_authorization";
9const VERSION: u8 = 1;
10const MAX_BYTES: usize = 4096;
11const SIGNING_DOMAIN: &[u8] = b"paykit.noise_key_authorization/v1\0";
12
13/// Pubky identity approval of Noise routing and handshake keys at one generation.
14///
15/// Deserialization verifies the signature. Network readers must additionally
16/// check the expected owner. Freshness depends on the owner's homeserver;
17/// retain the highest observed generation to reject subsequent rollbacks.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(try_from = "AuthorizationWire", into = "AuthorizationWire")]
20pub struct PaykitNoiseKeyAuthorization {
21    owner: PublicKey,
22    noise_public_key: PublicKey,
23    noise_static_public_key: [u8; 32],
24    key_generation: u64,
25    signature: String,
26}
27
28impl PaykitNoiseKeyAuthorization {
29    /// Sign with the Pubky identity key, never with delegated Paykit key material.
30    /// Both public keys are derived from the supplied Noise static secret.
31    pub fn sign(
32        identity: &pubky::Keypair,
33        noise_secret_key: &[u8; 32],
34        key_generation: u64,
35    ) -> Result<Self> {
36        if key_generation == 0 {
37            return Err(PaykitError::Validation(
38                "Paykit key generation must be positive".into(),
39            ));
40        }
41        let owner = identity.public_key();
42        let noise_public_key = pubky::Keypair::from_secret(noise_secret_key).public_key();
43        let noise_static_public_key = pubky_noise::derive_static_public_key(noise_secret_key);
44        let signature = identity.sign(&signing_bytes(
45            &owner,
46            &noise_public_key,
47            &noise_static_public_key,
48            key_generation,
49        ));
50        Ok(Self {
51            owner,
52            noise_public_key,
53            noise_static_public_key,
54            key_generation,
55            signature: STANDARD.encode(signature.to_bytes()),
56        })
57    }
58
59    /// Identity that authorized this key.
60    pub fn owner(&self) -> &PublicKey {
61        &self.owner
62    }
63
64    /// Ed25519 routing key approved for this identity.
65    pub fn noise_public_key(&self) -> &PublicKey {
66        &self.noise_public_key
67    }
68
69    /// X25519 static key that the Noise handshake peer must present.
70    pub fn noise_static_public_key(&self) -> &[u8; 32] {
71        &self.noise_static_public_key
72    }
73
74    /// Monotonically increasing Paykit key generation.
75    pub fn key_generation(&self) -> u64 {
76        self.key_generation
77    }
78
79    /// Reject another owner, a lower generation, or a different key at the same generation.
80    /// A reader may skip generations while offline.
81    pub fn validate_against(&self, previous: &Self) -> Result<()> {
82        if self.owner != previous.owner
83            || self.key_generation < previous.key_generation
84            || (self.key_generation == previous.key_generation
85                && (self.noise_public_key != previous.noise_public_key
86                    || self.noise_static_public_key != previous.noise_static_public_key))
87        {
88            return Err(invalid_data(
89                "Paykit Noise key authorization conflicts with the previously verified key",
90                None,
91            ));
92        }
93        Ok(())
94    }
95}
96
97// Fixed-width fields avoid JSON serialization or separator ambiguities.
98fn signing_bytes(
99    owner: &PublicKey,
100    noise: &PublicKey,
101    noise_static: &[u8; 32],
102    generation: u64,
103) -> Vec<u8> {
104    [
105        SIGNING_DOMAIN,
106        owner.as_bytes(),
107        noise.as_bytes(),
108        noise_static,
109        &generation.to_be_bytes(),
110    ]
111    .concat()
112}
113
114#[derive(Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116struct AuthorizationWire {
117    version: u8,
118    kind: String,
119    owner: PublicKey,
120    noise_public_key: PublicKey,
121    noise_static_public_key: String,
122    key_generation: u64,
123    signature: String,
124}
125
126impl From<PaykitNoiseKeyAuthorization> for AuthorizationWire {
127    fn from(record: PaykitNoiseKeyAuthorization) -> Self {
128        Self {
129            version: VERSION,
130            kind: KIND.into(),
131            owner: record.owner,
132            noise_public_key: record.noise_public_key,
133            noise_static_public_key: hex::encode(record.noise_static_public_key),
134            key_generation: record.key_generation,
135            signature: record.signature,
136        }
137    }
138}
139
140impl TryFrom<AuthorizationWire> for PaykitNoiseKeyAuthorization {
141    type Error = PaykitError;
142
143    fn try_from(wire: AuthorizationWire) -> Result<Self> {
144        if wire.version != VERSION || wire.kind != KIND || wire.key_generation == 0 {
145            return Err(invalid_data(
146                "invalid Paykit Noise key authorization header",
147                None,
148            ));
149        }
150        let mut noise_static_public_key = [0; 32];
151        hex::decode_to_slice(&wire.noise_static_public_key, &mut noise_static_public_key)
152            .map_err(|err| invalid_data("invalid Noise static public key", Some(err.into())))?;
153        let bytes = STANDARD.decode(&wire.signature).map_err(|err| {
154            invalid_data(
155                "invalid Paykit Noise key authorization signature",
156                Some(err.into()),
157            )
158        })?;
159        let signature = bytes.as_slice().try_into().map_err(|_| {
160            invalid_data(
161                "invalid Paykit Noise key authorization signature length",
162                None,
163            )
164        })?;
165        wire.owner
166            .verify(
167                &signing_bytes(
168                    &wire.owner,
169                    &wire.noise_public_key,
170                    &noise_static_public_key,
171                    wire.key_generation,
172                ),
173                &signature,
174            )
175            .map_err(|err| {
176                invalid_data(
177                    "Paykit Noise key authorization signature verification failed",
178                    Some(err.into()),
179                )
180            })?;
181        Ok(Self {
182            owner: wire.owner,
183            noise_public_key: wire.noise_public_key,
184            noise_static_public_key,
185            key_generation: wire.key_generation,
186            signature: wire.signature,
187        })
188    }
189}
190
191fn parse(bytes: &[u8], owner: &PublicKey) -> Result<PaykitNoiseKeyAuthorization> {
192    let record: PaykitNoiseKeyAuthorization = serde_json::from_slice(bytes)
193        .map_err(|err| invalid_data("invalid Paykit Noise key authorization", Some(err.into())))?;
194    if record.owner() != owner {
195        return Err(invalid_data(
196            "Paykit Noise key authorization belongs to another identity",
197            None,
198        ));
199    }
200    Ok(record)
201}
202
203/// Read and verify the identity's current Noise key. Missing records return `None`.
204///
205/// The caller is responsible for session creation, capability scope, key rotation,
206/// and Pubky request timeouts. This record has no expiry: the homeserver must
207/// return its current contents, and peers should retain verified generations.
208pub async fn get_paykit_noise_key_authorization(
209    storage: &pubky::PublicStorage,
210    owner: &PublicKey,
211) -> Result<Option<PaykitNoiseKeyAuthorization>> {
212    pubky_routing::fetch_text(
213        storage,
214        format!(
215            "{owner}{}",
216            pubky_routing::PAYKIT_NOISE_KEY_AUTHORIZATION_PATH
217        ),
218        "fetch Paykit Noise key authorization",
219        Some(MAX_BYTES),
220    )
221    .await?
222    .map(|body| parse(body.as_bytes(), owner))
223    .transpose()
224}
225
226/// Publish a signed Noise key authorization under an exclusive homeserver lock.
227///
228/// Requires separate write access to `/pub/paykit-authority/v0/current-key.json`.
229/// Never delegate that access to ordinary Paykit apps. Replacement advances
230/// exactly one generation. Repeating the same publication is harmless.
231/// Persist the rotated shared state before publishing.
232/// Session creation, capability scope, key rotation, and timeouts are the caller's
233/// responsibility. The homeserver must fence writes after lock ownership is lost.
234pub async fn publish_paykit_noise_key_authorization(
235    session: &pubky::PubkySession,
236    record: &PaykitNoiseKeyAuthorization,
237) -> Result<()> {
238    if session.info().public_key() != record.owner() {
239        return Err(PaykitError::Validation(
240            "Noise key authorizer session belongs to another identity".into(),
241        ));
242    }
243    let path = pubky_routing::PAYKIT_NOISE_KEY_AUTHORIZATION_PATH;
244    pubky_routing::with_write_lock(session, path, |lock| async move {
245        let previous = match session.storage().get(path).await {
246            Ok(response) => {
247                let bytes = pubky_routing::read_bounded_body(
248                    response,
249                    MAX_BYTES,
250                    "read current Noise key authorization",
251                )
252                .await?;
253                Some(parse(&bytes, record.owner())?)
254            }
255            Err(err) if pubky_routing::is_not_found(&err) => None,
256            Err(err) => {
257                return Err(PaykitError::Transport {
258                    context: "read current Noise key authorization".into(),
259                    source: err.into(),
260                })
261            }
262        };
263        if let Some(previous) = previous {
264            record.validate_against(&previous)?;
265            if record.key_generation == previous.key_generation {
266                return Ok(());
267            }
268            if previous.key_generation.checked_add(1) != Some(record.key_generation) {
269                return Err(PaykitError::Validation(
270                    "Noise key authorization must advance one generation".into(),
271                ));
272            }
273        }
274        let body =
275            serde_json::to_vec(record).map_err(|err| PaykitError::Validation(err.to_string()))?;
276        session
277            .storage()
278            .put_locked(&lock, body)
279            .await
280            .map_err(|err| PaykitError::Transport {
281                context: "publish Paykit Noise key authorization".into(),
282                source: err.into(),
283            })?;
284        Ok(())
285    })
286    .await
287}
288
289#[cfg(test)]
290mod tests;