Skip to content

Commit f64f6bd

Browse files
committed
Add a simple public-key-based authenticator
JWT-based authentication is currently largely the default due to its integration in `ldk-node` indirectly via LNURL-auth. This is great, but massively over-engineered (and requiring yet another service devs have to set up and maintain) for just authenticating to a storage service (and maybe an LSP). Here we add a much simpler authentication scheme, based simply on proof-of-knowledge of a private key. This allows for a simple VSS install without requiring any additional services. It relies on some higher-level authentication to limit new account registration, but that can be accomplished through more traditional anti-DoS systems like Apple DeviceCheck.
1 parent 0ace158 commit f64f6bd

5 files changed

Lines changed: 174 additions & 1 deletion

File tree

rust/auth-impls/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ edition = "2021"
55

66
[features]
77
jwt = [ "jsonwebtoken", "serde" ]
8+
sigs = [ "bitcoin_hashes", "hex-conservative", "secp256k1" ]
89

910
[dependencies]
1011
async-trait = "0.1.77"
1112
api = { path = "../api" }
1213
jsonwebtoken = { version = "9.3.0", optional = true, default-features = false, features = ["use_pem"] }
1314
serde = { version = "1.0.210", optional = true, default-features = false, features = ["derive"] }
1415

16+
bitcoin_hashes = { version = "0.19", optional = true, default-features = false }
17+
hex-conservative = { version = "1.0", optional = true, default-features = false }
18+
secp256k1 = { version = "0.31", optional = true, default-features = false, features = [ "global-context" ] }
19+
1520
[dev-dependencies]
1621
tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros"] }

rust/auth-impls/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@
1313

1414
#[cfg(feature = "jwt")]
1515
pub mod jwt;
16+
17+
#[cfg(feature = "sigs")]
18+
pub mod signature;

rust/auth-impls/src/signature.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//! Hosts a VSS protocol compliant [`Authorizer`] implementation that requires that every request
2+
//! come with a public key and proof of private key knowledge. Access is then granted to the user
3+
//! defined by the public key.
4+
//!
5+
//! Because no rate-limiting of new user accounts is done, a higher-level service is required to
6+
//! ensure requests are not triggering excess new user registrations.
7+
//!
8+
//! [`Authorizer`]: api::auth::Authorizer
9+
10+
use api::auth::{AuthResponse, Authorizer};
11+
use api::error::VssError;
12+
use async_trait::async_trait;
13+
use bitcoin_hashes::HashEngine;
14+
use std::collections::HashMap;
15+
use std::time::SystemTime;
16+
17+
/// A 64-byte constant which, after appending the public key, is signed in order to prove knowledge
18+
/// of the corresponding private key.
19+
pub const SIGNING_CONSTANT: &'static [u8] =
20+
b"VSS Signature Authorizer Signing Salt Constant..................";
21+
22+
/// An authorizer that requires that every request come with a public key and proof of private key
23+
/// knowledge. Access is then granted to the user defined by the public key.
24+
///
25+
/// The proof of private key knowledge takes the form of an ECDSA signature over the
26+
/// [`SIGNING_CONSTANT`] followed by the public key followed by the current time since the UNIX
27+
/// epoch, encoded as a string. It is expected to appear in the `Authorization` header, in the form
28+
/// of the hex-encoded 33-byte secp256k1 public key in compressed form followed by the hex-encoded
29+
/// 64-byte secp256k1 ECDSA signature followed by the signing time since the UNIX epoch, encoded as
30+
/// a string.
31+
///
32+
/// The proof will not be valid if the provided time is more than an hour from now.
33+
///
34+
/// Because no rate-limiting of new user accounts is done, a higher-level service is required to
35+
/// ensure requests are not triggering excess new user registrations.
36+
pub struct SignatureValidatingAuthorizer;
37+
38+
#[async_trait]
39+
impl Authorizer for SignatureValidatingAuthorizer {
40+
async fn verify(
41+
&self, headers_map: &HashMap<String, String>,
42+
) -> Result<AuthResponse, VssError> {
43+
let auth_header = headers_map
44+
.get("Authorization")
45+
.ok_or_else(|| VssError::AuthError("Authorization header not found.".to_string()))?;
46+
47+
if auth_header.len() <= (33 + 64) * 2 {
48+
return Err(VssError::AuthError("Authorization header has wrong length".to_string()));
49+
}
50+
if !auth_header.is_ascii() {
51+
return Err(VssError::AuthError("Authorization header has bogus chars".to_string()));
52+
}
53+
54+
let pubkey_hex = &auth_header[..33 * 2];
55+
let signat_hex = &auth_header[33 * 2..(33 + 64) * 2];
56+
let time_strng = &auth_header[(33 + 64) * 2..];
57+
58+
let pubkey_bytes: [u8; 33] = hex_conservative::decode_to_array(pubkey_hex)
59+
.map_err(|_| VssError::AuthError("Authorization header is not hex".to_string()))?;
60+
let sig_bytes: [u8; 64] = hex_conservative::decode_to_array(signat_hex)
61+
.map_err(|_| VssError::AuthError("Authorization header is not hex".to_string()))?;
62+
let time: u64 = time_strng
63+
.parse()
64+
.map_err(|_| VssError::AuthError("Time is not an integer".to_string()))?;
65+
66+
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap();
67+
if now.as_secs() - 60 * 60 * 24 > time || now.as_secs() + 60 * 60 * 24 < time {
68+
return Err(VssError::AuthError("Time is too far from now".to_string()))?;
69+
}
70+
71+
let pubkey = secp256k1::PublicKey::from_byte_array_compressed(pubkey_bytes)
72+
.map_err(|_| VssError::AuthError("Authorization header has bad pubkey".to_string()))?;
73+
let sig = secp256k1::ecdsa::Signature::from_compact(&sig_bytes)
74+
.map_err(|_| VssError::AuthError("Authorization header has bad sig".to_string()))?;
75+
76+
let mut hash = bitcoin_hashes::Sha256::engine();
77+
hash.input(&SIGNING_CONSTANT);
78+
hash.input(&pubkey_bytes);
79+
hash.input(time_strng.as_bytes());
80+
let signed_hash = secp256k1::Message::from_digest(hash.finalize().to_byte_array());
81+
sig.verify(signed_hash, &pubkey)
82+
.map_err(|_| VssError::AuthError("Signature was invalid".to_string()))?;
83+
84+
Ok(AuthResponse { user_token: pubkey_hex.to_owned() })
85+
}
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use crate::signature::{SignatureValidatingAuthorizer, SIGNING_CONSTANT};
91+
use api::auth::Authorizer;
92+
use api::error::VssError;
93+
use secp256k1::{Message, PublicKey, Secp256k1, SecretKey};
94+
use std::collections::HashMap;
95+
use std::fmt::Write;
96+
use std::time::SystemTime;
97+
98+
fn build_token(now: u64) -> (String, PublicKey) {
99+
let secret_key = SecretKey::from_byte_array([42; 32]).unwrap();
100+
let pubkey = secret_key.public_key(secp256k1::SECP256K1);
101+
102+
let mut bytes_to_sign = Vec::new();
103+
bytes_to_sign.extend_from_slice(SIGNING_CONSTANT);
104+
bytes_to_sign.extend_from_slice(&pubkey.serialize());
105+
bytes_to_sign.extend_from_slice(format!("{now}").as_bytes());
106+
let hash = bitcoin_hashes::Sha256::hash(&bytes_to_sign);
107+
let sig = secret_key.sign_ecdsa(Message::from_digest(hash.to_byte_array()));
108+
let mut sig_hex = String::with_capacity(64 * 2);
109+
for c in sig.serialize_compact() {
110+
write!(&mut sig_hex, "{:02x}", c).unwrap();
111+
}
112+
(format!("{pubkey:x}{sig_hex}{now}"), pubkey)
113+
}
114+
115+
#[tokio::test]
116+
async fn test_sig() {
117+
let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
118+
let mut headers_map = HashMap::new();
119+
let auth = SignatureValidatingAuthorizer;
120+
121+
// Test a valid signature
122+
let (token, pubkey) = build_token(now);
123+
headers_map.insert("Authorization".to_string(), token);
124+
assert_eq!(auth.verify(&headers_map).await.unwrap().user_token, format!("{pubkey:x}"));
125+
126+
// Test a signature too far in the future
127+
let (token, _) = build_token(now + 60 * 60 * 24 + 10);
128+
headers_map.insert("Authorization".to_string(), token);
129+
assert!(matches!(auth.verify(&headers_map).await.unwrap_err(), VssError::AuthError(_)));
130+
131+
// Test a signature too far in the past
132+
let (token, _) = build_token(now - 60 * 60 * 24 - 10);
133+
headers_map.insert("Authorization".to_string(), token);
134+
assert!(matches!(auth.verify(&headers_map).await.unwrap_err(), VssError::AuthError(_)));
135+
136+
// Test a token with an invalid signature
137+
let (mut token, _) = build_token(now);
138+
token = token
139+
.chars()
140+
.enumerate()
141+
.map(|(idx, c)| if idx == 33 * 2 + 10 || idx == 33 * 2 + 11 { '0' } else { c })
142+
.collect();
143+
headers_map.insert("Authorization".to_string(), token);
144+
assert!(matches!(auth.verify(&headers_map).await.unwrap_err(), VssError::AuthError(_)));
145+
146+
// Test a token with the wrong public key
147+
let (mut token, _) = build_token(now);
148+
token = token
149+
.chars()
150+
.enumerate()
151+
.map(|(idx, c)| if idx == 10 || idx == 11 { '0' } else { c })
152+
.collect();
153+
headers_map.insert("Authorization".to_string(), token);
154+
assert!(matches!(auth.verify(&headers_map).await.unwrap_err(), VssError::AuthError(_)));
155+
}
156+
}

rust/server/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ edition = "2021"
55

66
[features]
77
jwt = ["auth-impls/jwt"]
8-
default = [ "jwt" ]
8+
sigs = ["auth-impls/sigs"]
9+
default = [ "jwt", "sigs" ]
910

1011
[dependencies]
1112
api = { path = "../api" }

rust/server/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ use api::auth::{Authorizer, NoopAuthorizer};
2222
use api::kv_store::KvStore;
2323
#[cfg(feature = "jwt")]
2424
use auth_impls::jwt::JWTAuthorizer;
25+
#[cfg(feature = "sigs")]
26+
use auth_impls::signature::SignatureValidatingAuthorizer;
2527
use impls::postgres_store::{Certificate, PostgresPlaintextBackend, PostgresTlsBackend};
2628
use util::config::{Config, ServerConfig};
2729
use vss_service::VssService;
@@ -94,6 +96,12 @@ fn main() {
9496
};
9597
}
9698
}
99+
#[cfg(feature = "sigs")]
100+
{
101+
if authorizer.is_none() {
102+
authorizer = Some(Arc::new(SignatureValidatingAuthorizer));
103+
}
104+
}
97105
let authorizer = if let Some(auth) = authorizer {
98106
auth
99107
} else {

0 commit comments

Comments
 (0)