From aaf01cf2d77d0d192b882d8c1ee06c63c9b6fee6 Mon Sep 17 00:00:00 2001 From: nazeh Date: Tue, 27 Aug 2024 17:15:25 +0300 Subject: [PATCH] feat(common): add AuthToken with scopes --- Cargo.lock | 26 ++-- Cargo.toml | 4 + pubky-common/Cargo.toml | 6 +- pubky-common/src/auth.rs | 231 ++++++++++++++++++++++++++++++---- pubky-common/src/timestamp.rs | 4 +- pubky-homeserver/Cargo.toml | 6 +- pubky/Cargo.toml | 6 +- 7 files changed, 235 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72bffff..df600ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,9 +336,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.1" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cc" @@ -1401,10 +1401,10 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkarr" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4548c673cbf8c91b69f7a17d3a042710aa73cffe5e82351db5378f26c3be64d8" +version = "2.2.0" +source = "git+https://github.com/Pubky/pkarr?branch=v3#17975121c809d97dcad907fbb2ffc782e994d270" dependencies = [ + "base32", "bytes", "document-features", "dyn-clone", @@ -1416,13 +1416,13 @@ dependencies = [ "mainline", "rand", "self_cell", + "serde", "simple-dns", "thiserror", "tracing", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "z32", ] [[package]] @@ -1753,9 +1753,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.204" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09" dependencies = [ "serde_derive", ] @@ -1781,9 +1781,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.209" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170" dependencies = [ "proc-macro2", "quote", @@ -2616,12 +2616,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "z32" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb37266251c28b03d08162174a91c3a092e3bd4f476f8205ee1c507b78b7bdc" - [[package]] name = "zeroize" version = "1.8.1" diff --git a/Cargo.toml b/Cargo.toml index 9e2e527..4fcef4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ members = [ "pubky","pubky-*"] # See: https://github.com/rust-lang/rust/issues/90148#issuecomment-949194352 resolver = "2" +[workspace.dependencies] +pkarr = { git = "https://github.com/Pubky/pkarr", branch = "v3", package = "pkarr", features = ["async"] } +serde = { version = "^1.0.209", features = ["derive"] } + [profile.release] lto = true opt-level = 'z' diff --git a/pubky-common/Cargo.toml b/pubky-common/Cargo.toml index 202a815..675ec65 100644 --- a/pubky-common/Cargo.toml +++ b/pubky-common/Cargo.toml @@ -10,13 +10,13 @@ base32 = "0.5.0" blake3 = "1.5.1" ed25519-dalek = "2.1.1" once_cell = "1.19.0" -pkarr = "2.1.0" +pkarr = { workspace = true } rand = "0.8.5" thiserror = "1.0.60" postcard = { version = "1.0.8", features = ["alloc"] } crypto_secretbox = { version = "0.1.1", features = ["std"] } -serde = { version = "1.0.204", features = ["derive"], optional = true } +serde = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.69" @@ -26,7 +26,7 @@ postcard = "1.0.8" [features] -serde = ["dep:serde", "ed25519-dalek/serde"] +serde = ["dep:serde", "ed25519-dalek/serde", "pkarr/serde"] full = ['serde'] default = ['full'] diff --git a/pubky-common/src/auth.rs b/pubky-common/src/auth.rs index 5d5ebba..164a124 100644 --- a/pubky-common/src/auth.rs +++ b/pubky-common/src/auth.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use ed25519_dalek::ed25519::SignatureBytes; +use serde::{Deserialize, Serialize}; use crate::{ crypto::{random_hash, Keypair, PublicKey, Signature}, @@ -12,9 +13,184 @@ use crate::{ // 30 seconds const TIME_INTERVAL: u64 = 30 * 1_000_000; +const CURRENT_VERSION: u8 = 0; +// 45 seconds in the past or the future +const TIMESTAMP_WINDOW: i64 = 45 * 1_000_000; + #[derive(Debug, PartialEq)] pub struct AuthnSignature(Box<[u8]>); +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthToken { + /// Version of the [AuthToken]. + /// + /// Version 0: Signer is implicitly the same as the [AuthToken::subject] + version: u8, + /// The Pubky of the party verifying the [AuthToken], for example a web server. + audience: PublicKey, + /// Timestamp + timestamp: Timestamp, + /// The [PublicKey] of the owner of the resources being accessed by this token. + subject: PublicKey, + /// Signature over the token. + signature: Signature, + // Variable length scopes + scopes: Vec, +} + +impl AuthToken { + pub fn new(signer: &Keypair, audience: &PublicKey, scopes: Vec) -> Self { + let timestamp = Timestamp::now(); + + let signature = signer.sign(&AuthToken::signable(audience, ×tamp, &scopes)); + + Self { + version: 0, + subject: signer.public_key(), + audience: audience.to_owned(), + timestamp, + scopes, + signature, + } + } + + fn verify(audience: &PublicKey, bytes: &[u8]) -> Result { + if bytes[0] > CURRENT_VERSION { + return Err(Error::UnknownVersion); + } + + let token: AuthToken = postcard::from_bytes(bytes)?; + + let now = Timestamp::now(); + + match token.version { + 0 => { + if &token.audience != audience { + return Err(Error::InvalidAudience( + audience.to_string(), + token.audience.to_string(), + )); + } + + // Chcek timestamp; + let diff = token.timestamp.difference(&now); + if diff > TIMESTAMP_WINDOW { + return Err(Error::TooFarInTheFuture); + } + if diff < -TIMESTAMP_WINDOW { + return Err(Error::Expired); + } + + token + .subject + .verify( + &AuthToken::signable(&token.audience, &token.timestamp, &token.scopes), + &token.signature, + ) + .map_err(|_| Error::InvalidSignature)?; + + Ok(token) + } + _ => unreachable!(), + } + } + + fn signable(audience: &PublicKey, timestamp: &Timestamp, scopes: &Vec) -> Vec { + let serialized_scopes = &postcard::to_allocvec(&scopes).unwrap(); + + let mut signable = Vec::with_capacity(1 + 32 + 8 + serialized_scopes.len()); + + signable.extend_from_slice(&[CURRENT_VERSION]); + signable.extend_from_slice(audience.as_bytes()); + signable.extend_from_slice(×tamp.to_bytes()); + signable.extend_from_slice(serialized_scopes); + + signable + } + + /// A unique ID for this [AuthToken], which is a concatenation of + /// [AuthToken::subject] and [AuthToken::timestamp]. + /// + /// Assuming that [AuthToken::timestamp] is unique for every [AuthToken::subject]. + pub fn id(&self) -> [u8; 40] { + let mut id = [0u8; 40]; + id[0..32].copy_from_slice(&self.subject.to_bytes()); + id[32..].copy_from_slice(&self.timestamp.to_bytes()); + + id + } + + pub fn serialize(&self) -> Result, postcard::Error> { + postcard::to_allocvec(self) + } +} + +#[derive(Debug, Clone)] +pub struct AuthVerifier { + audience: PublicKey, + seen: Arc>>, +} + +impl AuthVerifier { + pub fn new(audience: PublicKey) -> Self { + Self { + audience, + seen: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn verify(&self, bytes: &[u8]) -> Result { + self.gc(); + + let token = AuthToken::verify(&self.audience, bytes)?; + + // Err(AuthnSignatureError::AlreadyUsed) => return Err(AuthnSignatureError::AlreadyUsed), + + let mut seen = self.seen.lock().unwrap(); + + let id = token.id(); + + match seen.binary_search_by(|element| element.cmp(&id)) { + Ok(index) | Err(index) => { + seen.insert(index, id); + } + }; + + Ok(token) + } + + // === Private Methods === + + /// Remove all tokens older than two time intervals in the past. + fn gc(&self) { + let threshold = ((Timestamp::now().into_inner() / TIME_INTERVAL) - 2).to_be_bytes(); + + let mut inner = self.seen.lock().unwrap(); + + match inner.binary_search_by(|element| element[0..8].cmp(&threshold)) { + Ok(index) | Err(index) => { + inner.drain(0..index); + } + } + } +} + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum Error { + #[error("Unknown version")] + UnknownVersion, + #[error("Invalid audience. Expected {0}, got {1}")] + InvalidAudience(String, String), + #[error("AuthToken has a timestamp that is more than 45 seconds in the future")] + TooFarInTheFuture, + #[error("AuthToken has a timestamp that is more than 45 seconds in the past")] + Expired, + #[error("Invalid Signature")] + InvalidSignature, + #[error(transparent)] + Postcard(#[from] postcard::Error), +} + impl AuthnSignature { pub fn new(signer: &Keypair, audience: &PublicKey, token: Option<&[u8]>) -> Self { let mut bytes = Vec::with_capacity(96); @@ -183,38 +359,51 @@ pub enum AuthnSignatureError { #[cfg(test)] mod tests { - use crate::crypto::Keypair; + use crate::{auth::TIMESTAMP_WINDOW, crypto::Keypair, timestamp::Timestamp}; - use super::{AuthnSignature, AuthnVerifier}; + use super::{AuthToken, AuthVerifier, Error}; #[test] fn sign_verify() { - let keypair = Keypair::random(); - let signer = keypair.public_key(); + let signer = Keypair::random(); let audience = Keypair::random().public_key(); + let scopes = vec!["*:*".to_string()]; - let verifier = AuthnVerifier::new(audience.clone()); + let verifier = AuthVerifier::new(audience.clone()); - let authn_signature = AuthnSignature::generate(&keypair, &audience); + let token = AuthToken::new(&signer, &audience, scopes.clone()); - verifier - .verify(authn_signature.as_bytes(), &signer) - .unwrap(); + verifier.verify(&token.serialize().unwrap()).unwrap(); - { - // Invalid signable - let mut invalid = authn_signature.as_bytes().to_vec(); - invalid[64..].copy_from_slice(&[0; 32]); + assert_eq!(token.scopes, scopes) + } - assert!(verifier.verify(&invalid, &signer).is_err()) - } + #[test] + fn expired() { + let signer = Keypair::random(); + let audience = Keypair::random().public_key(); + let scopes = vec!["*:*".to_string()]; - { - // Invalid signer - let mut invalid = authn_signature.as_bytes().to_vec(); - invalid[0..32].copy_from_slice(&[0; 32]); + let verifier = AuthVerifier::new(audience.clone()); - assert!(verifier.verify(&invalid, &signer).is_err()) - } + let timestamp = (&Timestamp::now()) - (TIMESTAMP_WINDOW as u64); + + let signable = AuthToken::signable(&audience, ×tamp, &scopes); + let signature = signer.sign(&signable); + + let token = AuthToken { + version: 0, + subject: signer.public_key(), + audience, + timestamp, + signature, + scopes, + }; + + let serialized = token.serialize().unwrap(); + + let result = verifier.verify(&serialized); + + assert_eq!(result, Err(Error::Expired)); } } diff --git a/pubky-common/src/timestamp.rs b/pubky-common/src/timestamp.rs index 0235f66..848f894 100644 --- a/pubky-common/src/timestamp.rs +++ b/pubky-common/src/timestamp.rs @@ -75,8 +75,8 @@ impl Timestamp { self.0.to_be_bytes() } - pub fn difference(&self, rhs: &Timestamp) -> u64 { - self.0.abs_diff(rhs.0) + pub fn difference(&self, rhs: &Timestamp) -> i64 { + (self.0 as i64) - (rhs.0 as i64) } pub fn into_inner(&self) -> u64 { diff --git a/pubky-homeserver/Cargo.toml b/pubky-homeserver/Cargo.toml index 68323b9..c8abfd5 100644 --- a/pubky-homeserver/Cargo.toml +++ b/pubky-homeserver/Cargo.toml @@ -8,17 +8,17 @@ anyhow = "1.0.82" axum = { version = "0.7.5", features = ["macros"] } axum-extra = { version = "0.9.3", features = ["typed-header", "async-read-body"] } base32 = "0.5.1" -bytes = "1.6.1" +bytes = "^1.7.1" clap = { version = "4.5.11", features = ["derive"] } dirs-next = "2.0.0" flume = "0.11.0" futures-util = "0.3.30" heed = "0.20.3" hex = "0.4.3" -pkarr = { version = "2.1.0", features = ["async"] } +pkarr = { workspace = true } postcard = { version = "1.0.8", features = ["alloc"] } pubky-common = { version = "0.1.0", path = "../pubky-common" } -serde = { version = "1.0.204", features = ["derive"] } +serde = { workspace = true } tokio = { version = "1.37.0", features = ["full"] } toml = "0.8.19" tower-cookies = "0.10.0" diff --git a/pubky/Cargo.toml b/pubky/Cargo.toml index 4c82cfb..90039ee 100644 --- a/pubky/Cargo.toml +++ b/pubky/Cargo.toml @@ -14,17 +14,17 @@ crate-type = ["cdylib", "rlib"] thiserror = "1.0.62" wasm-bindgen = "0.2.92" url = "2.5.2" -bytes = "1.6.1" +bytes = "^1.7.1" pubky-common = { version = "0.1.0", path = "../pubky-common" } argon2 = { version = "0.5.3", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -pkarr = { version="2.1.0", features = ["async"] } +pkarr = { workspace = true, features = ["async"] } reqwest = { version = "0.12.5", features = ["cookies"], default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] -pkarr = { version = "2.1.0", default-features = false } +pkarr = { workspace = true, default-features = false } reqwest = { version = "0.12.5", default-features = false } js-sys = "0.3.69"