Skip to content

Commit b828ef6

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 b828ef6

File tree

6 files changed

+179
-3
lines changed

6 files changed

+179
-3
lines changed

.github/workflows/ldk-node-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
run: |
4141
cd vss-server/rust
4242
cargo build
43-
cargo run server/vss-server-config.toml&
43+
cargo run --no-default-features server/vss-server-config.toml&
4444
- name: Run LDK Node Integration tests
4545
run: |
4646
cd ldk-node

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

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: 10 additions & 1 deletion
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,10 +96,17 @@ fn main() {
9496
};
9597
}
9698
}
99+
#[cfg(feature = "sigs")]
100+
{
101+
if authorizer.is_none() {
102+
println!("Configured signature-validating authorizer");
103+
authorizer = Some(Arc::new(SignatureValidatingAuthorizer));
104+
}
105+
}
97106
let authorizer = if let Some(auth) = authorizer {
98107
auth
99108
} else {
100-
println!("No authentication method configured, all storage will be comingled");
109+
println!("No authentication method configured, all storage with the same store id will be commingled.");
101110
Arc::new(NoopAuthorizer {})
102111
};
103112

0 commit comments

Comments
 (0)