@@ -40,11 +40,12 @@ const COOKIE_TOKEN_BYTES: usize = 32;
4040/// Upper bound on the inbound `Authorization` header to cap base64 decode allocation.
4141const MAX_AUTH_HEADER_LEN : usize = 16 * 1024 ;
4242
43- /// Generate a fresh cookie and write it to `path` with no trailing newline.
43+ /// Generate a fresh cookie, write it to `path` with no trailing newline, and
44+ /// return the `__cookie__:<hex>` line for in-process validation.
4445///
4546/// Writes go to `<path>.tmp` first with mode `0600` on Unix, then atomically
4647/// rename over `path`. A pre-existing cookie file is silently overwritten.
47- pub ( crate ) fn generate_cookie ( path : & Path ) -> io:: Result < ( ) > {
48+ pub ( crate ) fn generate_cookie ( path : & Path ) -> io:: Result < String > {
4849 let mut token = [ 0u8 ; COOKIE_TOKEN_BYTES ] ;
4950 rand:: rng ( ) . fill ( & mut token) ;
5051 let auth = format ! ( "{COOKIE_USER}:{}" , token. to_lower_hex_string( ) ) ;
@@ -65,7 +66,7 @@ pub(crate) fn generate_cookie(path: &Path) -> io::Result<()> {
6566
6667 fs:: rename ( & tmp, path) ?;
6768
68- Ok ( ( ) )
69+ Ok ( auth )
6970}
7071
7172/// Errors produced by [`parse_basic_auth_header`].
@@ -100,26 +101,52 @@ impl fmt::Display for BasicAuthHeaderError {
100101
101102impl std:: error:: Error for BasicAuthHeaderError { }
102103
103- /// Axum middleware that parses an inbound `Authorization: Basic` header and
104- /// logs the parsed username at debug level. Requests without the header or
105- /// with a malformed value are passed through unchanged; this layer does not
106- /// reject anything .
104+ /// Axum middleware that gates each request on the configured [`Credentials`].
105+ /// Missing, malformed, non-ASCII, or non-matching `Authorization: Basic`
106+ /// headers all return HTTP 401 with `WWW-Authenticate: Basic realm="jsonrpc"`
107+ /// per RFC 7235. Matching requests pass through to the handler .
107108pub ( crate ) async fn auth_middleware (
109+ axum:: extract:: State ( creds) : axum:: extract:: State < std:: sync:: Arc < Credentials > > ,
108110 req : axum:: extract:: Request ,
109111 next : axum:: middleware:: Next ,
110112) -> axum:: response:: Response {
111- if let Some ( header) = req. headers ( ) . get ( axum:: http:: header:: AUTHORIZATION ) {
112- match header. to_str ( ) {
113- Ok ( value) => match parse_basic_auth_header ( value) {
114- Ok ( ( user, _) ) => tracing:: debug!( "rpc auth header parsed for user {user}" ) ,
115- Err ( e) => tracing:: debug!( "rpc auth header parse failed: {e}" ) ,
116- } ,
117- Err ( _) => tracing:: debug!( "rpc auth header is not valid ascii" ) ,
113+ let Some ( header) = req. headers ( ) . get ( axum:: http:: header:: AUTHORIZATION ) else {
114+ tracing:: debug!( "rpc auth header missing; rejecting" ) ;
115+ return unauthorized ( ) ;
116+ } ;
117+ let value = match header. to_str ( ) {
118+ Ok ( s) => s,
119+ Err ( _) => {
120+ tracing:: debug!( "rpc auth header is not valid ascii; rejecting" ) ;
121+ return unauthorized ( ) ;
118122 }
123+ } ;
124+ let ( user, pass) = match parse_basic_auth_header ( value) {
125+ Ok ( pair) => pair,
126+ Err ( e) => {
127+ tracing:: debug!( "rpc auth header parse failed: {e}; rejecting" ) ;
128+ return unauthorized ( ) ;
129+ }
130+ } ;
131+ if !creds. matches ( & user, & pass) {
132+ tracing:: debug!( "rpc auth credentials mismatched for user {user}; rejecting" ) ;
133+ return unauthorized ( ) ;
119134 }
135+ tracing:: debug!( "rpc auth ok for user {user}" ) ;
120136 next. run ( req) . await
121137}
122138
139+ fn unauthorized ( ) -> axum:: response:: Response {
140+ axum:: response:: Response :: builder ( )
141+ . status ( axum:: http:: StatusCode :: UNAUTHORIZED )
142+ . header (
143+ axum:: http:: header:: WWW_AUTHENTICATE ,
144+ r#"Basic realm="jsonrpc""# ,
145+ )
146+ . body ( axum:: body:: Body :: empty ( ) )
147+ . expect ( "static 401 response is always well-formed" )
148+ }
149+
123150/// Parse an HTTP `Authorization: Basic <b64>` header value into `(user, pass)`.
124151///
125152/// Mirrors Bitcoin Core: the `"Basic "` prefix check is case-sensitive, the
@@ -146,6 +173,41 @@ pub(crate) fn parse_basic_auth_header(
146173 Ok ( ( user. to_string ( ) , pass. to_string ( ) ) )
147174}
148175
176+ /// Configured RPC credentials for this process. The middleware compares each
177+ /// inbound `Authorization: Basic` request against the stored value via
178+ /// [`Credentials::matches`].
179+ pub ( crate ) enum Credentials {
180+ /// Cookie auth. Stores the full `__cookie__:<hex>` line as written to
181+ /// disk by [`generate_cookie`].
182+ Cookie ( String ) ,
183+ }
184+
185+ impl Credentials {
186+ /// True if the supplied basic-auth `user`/`pass` pair authenticates
187+ /// against the configured credentials. All comparisons are constant-time.
188+ pub ( crate ) fn matches ( & self , user : & str , pass : & str ) -> bool {
189+ match self {
190+ Self :: Cookie ( expected) => {
191+ constant_time_eq ( format ! ( "{user}:{pass}" ) . as_bytes ( ) , expected. as_bytes ( ) )
192+ }
193+ }
194+ }
195+ }
196+
197+ /// Constant-time byte slice comparison. Returns `false` immediately on length
198+ /// mismatch (lengths of both comparands are public), then XORs every byte into
199+ /// an accumulator before returning.
200+ pub ( crate ) fn constant_time_eq ( a : & [ u8 ] , b : & [ u8 ] ) -> bool {
201+ if a. len ( ) != b. len ( ) {
202+ return false ;
203+ }
204+ let mut acc: u8 = 0 ;
205+ for ( x, y) in a. iter ( ) . zip ( b. iter ( ) ) {
206+ acc |= x ^ y;
207+ }
208+ acc == 0
209+ }
210+
149211/// Remove the cookie file at `path`. Treats `NotFound` as success so shutdown
150212/// is idempotent. Caller must only invoke this after a successful
151213/// [`generate_cookie`] in this process.
@@ -181,14 +243,13 @@ mod tests {
181243 #[ test]
182244 fn generate_cookie_writes_expected_format ( ) {
183245 let path = tmp_cookie_path ( "format" ) ;
184- generate_cookie ( & path) . unwrap ( ) ;
246+ let auth = generate_cookie ( & path) . unwrap ( ) ;
185247
186- let written = fs:: read_to_string ( & path) . unwrap ( ) ;
187248 assert ! (
188- written . starts_with( "__cookie__:" ) ,
189- "cookie file missing prefix: {written }"
249+ auth . starts_with( "__cookie__:" ) ,
250+ "auth string missing prefix: {auth }"
190251 ) ;
191- let token = written . strip_prefix ( "__cookie__:" ) . unwrap ( ) ;
252+ let token = auth . strip_prefix ( "__cookie__:" ) . unwrap ( ) ;
192253 assert_eq ! (
193254 token. len( ) ,
194255 64 ,
@@ -202,6 +263,12 @@ mod tests {
202263 "token should be lowercase hex: {token}" ,
203264 ) ;
204265
266+ let written = fs:: read_to_string ( & path) . unwrap ( ) ;
267+ assert_eq ! (
268+ written, auth,
269+ "file content should match returned auth string"
270+ ) ;
271+
205272 fs:: remove_file ( & path) . ok ( ) ;
206273 }
207274
@@ -220,10 +287,8 @@ mod tests {
220287 fn generate_cookie_produces_distinct_tokens ( ) {
221288 let path1 = tmp_cookie_path ( "distinct1" ) ;
222289 let path2 = tmp_cookie_path ( "distinct2" ) ;
223- generate_cookie ( & path1) . unwrap ( ) ;
224- generate_cookie ( & path2) . unwrap ( ) ;
225- let auth1 = fs:: read_to_string ( & path1) . unwrap ( ) ;
226- let auth2 = fs:: read_to_string ( & path2) . unwrap ( ) ;
290+ let auth1 = generate_cookie ( & path1) . unwrap ( ) ;
291+ let auth2 = generate_cookie ( & path2) . unwrap ( ) ;
227292 assert_ne ! (
228293 auth1, auth2,
229294 "two consecutive calls produced identical tokens"
@@ -237,12 +302,11 @@ mod tests {
237302 fn generate_cookie_overwrites_existing_file ( ) {
238303 let path = tmp_cookie_path ( "overwrite" ) ;
239304 fs:: write ( & path, "stale-content" ) . unwrap ( ) ;
240- generate_cookie ( & path) . unwrap ( ) ;
305+ let auth = generate_cookie ( & path) . unwrap ( ) ;
241306 let written = fs:: read_to_string ( & path) . unwrap ( ) ;
242- assert_ne ! ( written, "stale-content" , "stale content was not replaced" ) ;
243- assert ! (
244- written. starts_with( "__cookie__:" ) ,
245- "replacement is not a cookie line: {written}"
307+ assert_eq ! (
308+ written, auth,
309+ "file content should match returned auth string"
246310 ) ;
247311
248312 fs:: remove_file ( & path) . ok ( ) ;
@@ -369,6 +433,39 @@ mod tests {
369433 ) ;
370434 }
371435
436+ #[ test]
437+ fn constant_time_eq_returns_true_for_equal_bytes ( ) {
438+ assert ! ( constant_time_eq( b"abcdef" , b"abcdef" ) ) ;
439+ assert ! ( constant_time_eq( b"" , b"" ) ) ;
440+ }
441+
442+ #[ test]
443+ fn constant_time_eq_returns_false_for_different_bytes ( ) {
444+ assert ! ( !constant_time_eq( b"abcdef" , b"abcdeg" ) ) ;
445+ assert ! ( !constant_time_eq( b"abcdef" , b"xbcdef" ) ) ;
446+ }
447+
448+ #[ test]
449+ fn constant_time_eq_returns_false_for_length_mismatch ( ) {
450+ assert ! ( !constant_time_eq( b"abc" , b"abcd" ) ) ;
451+ assert ! ( !constant_time_eq( b"abcd" , b"abc" ) ) ;
452+ assert ! ( !constant_time_eq( b"" , b"a" ) ) ;
453+ }
454+
455+ #[ test]
456+ fn cookie_credentials_match_their_own_user_and_pass ( ) {
457+ let path = tmp_cookie_path ( "creds_cookie" ) ;
458+ let auth = generate_cookie ( & path) . unwrap ( ) ;
459+ let creds = Credentials :: Cookie ( auth. clone ( ) ) ;
460+
461+ let ( user, pass) = auth. split_once ( ':' ) . unwrap ( ) ;
462+ assert ! ( creds. matches( user, pass) ) ;
463+ assert ! ( !creds. matches( user, "wrong" ) ) ;
464+ assert ! ( !creds. matches( "wronguser" , pass) ) ;
465+
466+ fs:: remove_file ( & path) . ok ( ) ;
467+ }
468+
372469 #[ cfg( unix) ]
373470 #[ test]
374471 fn generate_cookie_sets_owner_only_mode_on_unix ( ) {
0 commit comments