use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use crate::{pubky_routing, validation::invalid_data, PaykitError, PublicKey, Result};
const KIND: &str = "paykit.noise_key_authorization";
const VERSION: u8 = 1;
const MAX_BYTES: usize = 4096;
const SIGNING_DOMAIN: &[u8] = b"paykit.noise_key_authorization/v1\0";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "AuthorizationWire", into = "AuthorizationWire")]
pub struct PaykitNoiseKeyAuthorization {
owner: PublicKey,
noise_public_key: PublicKey,
noise_static_public_key: [u8; 32],
key_generation: u64,
signature: String,
}
impl PaykitNoiseKeyAuthorization {
pub fn sign(
identity: &pubky::Keypair,
noise_secret_key: &[u8; 32],
key_generation: u64,
) -> Result<Self> {
if key_generation == 0 {
return Err(PaykitError::Validation(
"Paykit key generation must be positive".into(),
));
}
let owner = identity.public_key();
let noise_public_key = pubky::Keypair::from_secret(noise_secret_key).public_key();
let noise_static_public_key = pubky_noise::derive_static_public_key(noise_secret_key);
let signature = identity.sign(&signing_bytes(
&owner,
&noise_public_key,
&noise_static_public_key,
key_generation,
));
Ok(Self {
owner,
noise_public_key,
noise_static_public_key,
key_generation,
signature: STANDARD.encode(signature.to_bytes()),
})
}
pub fn owner(&self) -> &PublicKey {
&self.owner
}
pub fn noise_public_key(&self) -> &PublicKey {
&self.noise_public_key
}
pub fn noise_static_public_key(&self) -> &[u8; 32] {
&self.noise_static_public_key
}
pub fn key_generation(&self) -> u64 {
self.key_generation
}
pub fn validate_against(&self, previous: &Self) -> Result<()> {
if self.owner != previous.owner
|| self.key_generation < previous.key_generation
|| (self.key_generation == previous.key_generation
&& (self.noise_public_key != previous.noise_public_key
|| self.noise_static_public_key != previous.noise_static_public_key))
{
return Err(invalid_data(
"Paykit Noise key authorization conflicts with the previously verified key",
None,
));
}
Ok(())
}
}
fn signing_bytes(
owner: &PublicKey,
noise: &PublicKey,
noise_static: &[u8; 32],
generation: u64,
) -> Vec<u8> {
[
SIGNING_DOMAIN,
owner.as_bytes(),
noise.as_bytes(),
noise_static,
&generation.to_be_bytes(),
]
.concat()
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AuthorizationWire {
version: u8,
kind: String,
owner: PublicKey,
noise_public_key: PublicKey,
noise_static_public_key: String,
key_generation: u64,
signature: String,
}
impl From<PaykitNoiseKeyAuthorization> for AuthorizationWire {
fn from(record: PaykitNoiseKeyAuthorization) -> Self {
Self {
version: VERSION,
kind: KIND.into(),
owner: record.owner,
noise_public_key: record.noise_public_key,
noise_static_public_key: hex::encode(record.noise_static_public_key),
key_generation: record.key_generation,
signature: record.signature,
}
}
}
impl TryFrom<AuthorizationWire> for PaykitNoiseKeyAuthorization {
type Error = PaykitError;
fn try_from(wire: AuthorizationWire) -> Result<Self> {
if wire.version != VERSION || wire.kind != KIND || wire.key_generation == 0 {
return Err(invalid_data(
"invalid Paykit Noise key authorization header",
None,
));
}
let mut noise_static_public_key = [0; 32];
hex::decode_to_slice(&wire.noise_static_public_key, &mut noise_static_public_key)
.map_err(|err| invalid_data("invalid Noise static public key", Some(err.into())))?;
let bytes = STANDARD.decode(&wire.signature).map_err(|err| {
invalid_data(
"invalid Paykit Noise key authorization signature",
Some(err.into()),
)
})?;
let signature = bytes.as_slice().try_into().map_err(|_| {
invalid_data(
"invalid Paykit Noise key authorization signature length",
None,
)
})?;
wire.owner
.verify(
&signing_bytes(
&wire.owner,
&wire.noise_public_key,
&noise_static_public_key,
wire.key_generation,
),
&signature,
)
.map_err(|err| {
invalid_data(
"Paykit Noise key authorization signature verification failed",
Some(err.into()),
)
})?;
Ok(Self {
owner: wire.owner,
noise_public_key: wire.noise_public_key,
noise_static_public_key,
key_generation: wire.key_generation,
signature: wire.signature,
})
}
}
fn parse(bytes: &[u8], owner: &PublicKey) -> Result<PaykitNoiseKeyAuthorization> {
let record: PaykitNoiseKeyAuthorization = serde_json::from_slice(bytes)
.map_err(|err| invalid_data("invalid Paykit Noise key authorization", Some(err.into())))?;
if record.owner() != owner {
return Err(invalid_data(
"Paykit Noise key authorization belongs to another identity",
None,
));
}
Ok(record)
}
pub async fn get_paykit_noise_key_authorization(
storage: &pubky::PublicStorage,
owner: &PublicKey,
) -> Result<Option<PaykitNoiseKeyAuthorization>> {
pubky_routing::fetch_text(
storage,
format!(
"{owner}{}",
pubky_routing::PAYKIT_NOISE_KEY_AUTHORIZATION_PATH
),
"fetch Paykit Noise key authorization",
Some(MAX_BYTES),
)
.await?
.map(|body| parse(body.as_bytes(), owner))
.transpose()
}
pub async fn publish_paykit_noise_key_authorization(
session: &pubky::PubkySession,
record: &PaykitNoiseKeyAuthorization,
) -> Result<()> {
if session.info().public_key() != record.owner() {
return Err(PaykitError::Validation(
"Noise key authorizer session belongs to another identity".into(),
));
}
let path = pubky_routing::PAYKIT_NOISE_KEY_AUTHORIZATION_PATH;
pubky_routing::with_write_lock(session, path, |lock| async move {
let previous = match session.storage().get(path).await {
Ok(response) => {
let bytes = pubky_routing::read_bounded_body(
response,
MAX_BYTES,
"read current Noise key authorization",
)
.await?;
Some(parse(&bytes, record.owner())?)
}
Err(err) if pubky_routing::is_not_found(&err) => None,
Err(err) => {
return Err(PaykitError::Transport {
context: "read current Noise key authorization".into(),
source: err.into(),
})
}
};
if let Some(previous) = previous {
record.validate_against(&previous)?;
if record.key_generation == previous.key_generation {
return Ok(());
}
if previous.key_generation.checked_add(1) != Some(record.key_generation) {
return Err(PaykitError::Validation(
"Noise key authorization must advance one generation".into(),
));
}
}
let body =
serde_json::to_vec(record).map_err(|err| PaykitError::Validation(err.to_string()))?;
session
.storage()
.put_locked(&lock, body)
.await
.map_err(|err| PaykitError::Transport {
context: "publish Paykit Noise key authorization".into(),
source: err.into(),
})?;
Ok(())
})
.await
}
#[cfg(test)]
mod tests;