Skip to content

Commit b04b138

Browse files
committed
feat(rpc): hand-rolled sync HTTP/1.1 + sonic-rs JSON-RPC + Core-compat handler subset (no signing)
Op: extend
1 parent cb63f86 commit b04b138

18 files changed

Lines changed: 2056 additions & 1 deletion

Cargo.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/rpc/Cargo.toml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,37 @@ description = "bitcoin-rs :: rpc"
1111
[lints]
1212
workspace = true
1313

14+
[features]
15+
default = []
16+
rocksdb = ["bitcoin-rs-storage/rocksdb"]
17+
fjall = ["bitcoin-rs-storage/fjall"]
18+
redb = ["bitcoin-rs-storage/redb"]
19+
mdbx = ["bitcoin-rs-storage/mdbx"]
20+
1421
[dependencies]
22+
bitcoin-rs-primitives.workspace = true
23+
bitcoin-rs-consensus.workspace = true
24+
bitcoin-rs-chain.workspace = true
25+
bitcoin-rs-index.workspace = true
26+
bitcoin-rs-mempool.workspace = true
27+
bitcoin-rs-utxo.workspace = true
28+
bitcoin-rs-filters.workspace = true
29+
bitcoin-rs-coinstats.workspace = true
30+
bitcoin-rs-mining.workspace = true
31+
bitcoin-rs-wallet.workspace = true
32+
bitcoin-rs-storage.workspace = true
33+
bitcoin.workspace = true
34+
sonic-rs.workspace = true
35+
serde.workspace = true
36+
serde_json.workspace = true
37+
parking_lot.workspace = true
38+
arc-swap.workspace = true
39+
crossbeam-channel.workspace = true
40+
compact_str.workspace = true
41+
hashbrown.workspace = true
42+
thiserror.workspace = true
43+
tracing.workspace = true
44+
45+
[dev-dependencies]
46+
proptest.workspace = true
47+
tempfile = "3"

crates/rpc/src/auth.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
use std::fs;
2+
use std::io;
3+
use std::path::{Path, PathBuf};
4+
5+
use bitcoin::hashes::{Hash as _, sha256};
6+
use thiserror::Error;
7+
8+
/// RPC authentication policy.
9+
#[derive(Clone, Debug)]
10+
pub enum Auth {
11+
/// HTTP Basic auth with a cleartext username and SHA256 password digest.
12+
Basic {
13+
/// Expected username.
14+
user: String,
15+
/// SHA256 of the expected password.
16+
password_hash: [u8; 32],
17+
},
18+
/// Bitcoin Core cookie auth loaded from `path` during construction.
19+
Cookie {
20+
/// Cookie file path retained for diagnostics and reload decisions.
21+
path: PathBuf,
22+
/// Username read from the cookie file.
23+
user: String,
24+
/// SHA256 of the cookie password.
25+
password_hash: [u8; 32],
26+
},
27+
}
28+
29+
/// Authentication construction errors.
30+
#[derive(Debug, Error)]
31+
pub enum AuthError {
32+
/// Cookie file could not be read.
33+
#[error("cookie read failed: {0}")]
34+
Io(#[from] io::Error),
35+
/// Cookie contents were not `user:password`.
36+
#[error("cookie file must contain user:password")]
37+
InvalidCookie,
38+
}
39+
40+
impl Auth {
41+
/// Builds Basic auth by hashing `password` once at startup.
42+
#[must_use]
43+
pub fn basic(user: impl Into<String>, password: &str) -> Self {
44+
Self::Basic {
45+
user: user.into(),
46+
password_hash: hash_password(password),
47+
}
48+
}
49+
50+
/// Builds cookie auth by reading and hashing the cookie file once.
51+
pub fn cookie(path: impl AsRef<Path>) -> Result<Self, AuthError> {
52+
let path = path.as_ref().to_path_buf();
53+
let contents = fs::read_to_string(&path)?;
54+
let trimmed = contents.trim_end_matches(['\r', '\n']);
55+
let Some((user, password)) = trimmed.split_once(':') else {
56+
return Err(AuthError::InvalidCookie);
57+
};
58+
Ok(Self::Cookie {
59+
path,
60+
user: user.to_owned(),
61+
password_hash: hash_password(password),
62+
})
63+
}
64+
65+
/// Returns true when `Authorization` contains valid HTTP Basic credentials.
66+
#[must_use]
67+
pub fn validate_header(&self, header: Option<&str>) -> bool {
68+
let Some(header) = header else {
69+
return false;
70+
};
71+
let Some(encoded) = header.strip_prefix("Basic ") else {
72+
return false;
73+
};
74+
let Some(decoded) = decode_base64(encoded) else {
75+
return false;
76+
};
77+
let Ok(credentials) = core::str::from_utf8(&decoded) else {
78+
return false;
79+
};
80+
let Some((candidate_user, candidate_password)) = credentials.split_once(':') else {
81+
return false;
82+
};
83+
let candidate_hash = hash_password(candidate_password);
84+
match self {
85+
Self::Basic {
86+
user,
87+
password_hash,
88+
}
89+
| Self::Cookie {
90+
user,
91+
password_hash,
92+
..
93+
} => {
94+
constant_time_eq(candidate_user.as_bytes(), user.as_bytes())
95+
&& constant_time_eq(&candidate_hash, password_hash)
96+
}
97+
}
98+
}
99+
}
100+
101+
/// Compares byte strings without early exit.
102+
#[must_use]
103+
pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
104+
// SPEC: constant-time eq to avoid auth-timing leaks.
105+
let len = left.len().max(right.len());
106+
let mut diff = left.len() ^ right.len();
107+
let mut index = 0;
108+
while index < len {
109+
let l = left.get(index).copied().unwrap_or(0);
110+
let r = right.get(index).copied().unwrap_or(0);
111+
diff |= usize::from(l ^ r);
112+
index += 1;
113+
}
114+
diff == 0
115+
}
116+
117+
fn hash_password(password: &str) -> [u8; 32] {
118+
*sha256::Hash::hash(password.as_bytes()).as_byte_array()
119+
}
120+
121+
fn decode_base64(input: &str) -> Option<Vec<u8>> {
122+
let bytes = input.as_bytes();
123+
if !bytes.len().is_multiple_of(4) {
124+
return None;
125+
}
126+
127+
let mut output = Vec::with_capacity(bytes.len() / 4 * 3);
128+
let mut index = 0;
129+
while index < bytes.len() {
130+
let a = decode_base64_byte(bytes[index])?;
131+
let b = decode_base64_byte(bytes[index + 1])?;
132+
let c = if bytes[index + 2] == b'=' {
133+
64
134+
} else {
135+
decode_base64_byte(bytes[index + 2])?
136+
};
137+
let d = if bytes[index + 3] == b'=' {
138+
64
139+
} else {
140+
decode_base64_byte(bytes[index + 3])?
141+
};
142+
if c == 64 && d != 64 {
143+
return None;
144+
}
145+
output.push((a << 2) | (b >> 4));
146+
if c != 64 {
147+
output.push(((b & 0x0f) << 4) | (c >> 2));
148+
}
149+
if d != 64 {
150+
output.push(((c & 0x03) << 6) | d);
151+
}
152+
index += 4;
153+
}
154+
Some(output)
155+
}
156+
157+
fn decode_base64_byte(byte: u8) -> Option<u8> {
158+
match byte {
159+
b'A'..=b'Z' => Some(byte - b'A'),
160+
b'a'..=b'z' => Some(byte - b'a' + 26),
161+
b'0'..=b'9' => Some(byte - b'0' + 52),
162+
b'+' => Some(62),
163+
b'/' => Some(63),
164+
_ => None,
165+
}
166+
}

0 commit comments

Comments
 (0)