feat(common): add AuthToken with scopes

This commit is contained in:
nazeh
2024-08-27 17:15:25 +03:00
parent 19dd3cb2ba
commit aaf01cf2d7
7 changed files with 235 additions and 48 deletions

26
Cargo.lock generated
View File

@@ -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"

View File

@@ -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'

View File

@@ -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']

View File

@@ -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<String>,
}
impl AuthToken {
pub fn new(signer: &Keypair, audience: &PublicKey, scopes: Vec<String>) -> Self {
let timestamp = Timestamp::now();
let signature = signer.sign(&AuthToken::signable(audience, &timestamp, &scopes));
Self {
version: 0,
subject: signer.public_key(),
audience: audience.to_owned(),
timestamp,
scopes,
signature,
}
}
fn verify(audience: &PublicKey, bytes: &[u8]) -> Result<Self, Error> {
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<String>) -> Vec<u8> {
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(&timestamp.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<Vec<u8>, postcard::Error> {
postcard::to_allocvec(self)
}
}
#[derive(Debug, Clone)]
pub struct AuthVerifier {
audience: PublicKey,
seen: Arc<Mutex<Vec<[u8; 40]>>>,
}
impl AuthVerifier {
pub fn new(audience: PublicKey) -> Self {
Self {
audience,
seen: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn verify(&self, bytes: &[u8]) -> Result<AuthToken, Error> {
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, &timestamp, &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));
}
}

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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"