Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/api/src/auth/services/challenge.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ describe('ChallengeService', () => {
expect(expiresAt.getTime()).toBe(clock.now().getTime() + 300_000);
});

// The engine pins this exact shape before it will sign a challenge, and
// refuses anything else. Narrowing the tail here fails every login, so the
// contract is pinned on both sides rather than only in Rust.
it('issues a tail the engine will sign: 32 bytes of lowercase hex', () => {
const { challenge } = service.issueIdentityChallenge(PUBLIC_KEY);
expect(challenge).toMatch(/^cipherbox-login:v2:[0-9a-f]{64}$/);
});

it('consumes a live challenge exactly once', () => {
const { challenge } = service.issueIdentityChallenge(PUBLIC_KEY);
service.consume(challenge, 'identity', PUBLIC_KEY);
Expand Down
21 changes: 2 additions & 19 deletions crates/desktop-seams/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,8 @@ impl ReqwestHttp {
pub fn new() -> SeamResult<Self> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
// The engine decides over which transport a request carrying an
// `Authorization` header may go (blueprint/engine.md "Content
// plane"). A redirect that downgrades to plaintext would carry
// the credential onto the clear network past that decision, so
// it is surfaced as the 3xx it is rather than followed.
let downgrade = attempt.url().scheme() == "http"
&& attempt
.previous()
.last()
.is_some_and(|previous| previous.scheme() == "https");
if downgrade {
attempt.stop()
} else if attempt.previous().len() > 10 {
attempt.error("too many redirects")
} else {
attempt.follow()
}
}))
// No redirects; see the `Http` seam contract.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|err| SeamError::new(format!("http client build: {err}")))?;
Ok(Self { client })
Expand Down
28 changes: 28 additions & 0 deletions crates/desktop-seams/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,34 @@ async fn reqwest_http_returns_non_2xx_as_a_response_not_an_error() {
assert_eq!(response.status, 418);
}

/// The seam follows no redirect: the 3xx comes back as the response it is, and
/// the `Authorization` header never reaches the hop's target. A doc comment
/// cannot fail CI, so the policy is asserted against a live server.
#[tokio::test]
async fn reqwest_http_follows_no_redirect_and_does_not_replay_the_bearer() {
let server = MockServer::start();
let http = ReqwestHttp::new().expect("client builds");

let response = http
.send(HttpRequest {
method: HttpMethod::Get,
url: format!("{}/redirect", server.base_url()),
headers: vec![("Authorization".into(), "Bearer member-token".into())],
body: None,
credentials: HttpCredentials::Omit,
timeout_ms: None,
})
.await
.expect("a 3xx is a response, never a seam Err");

assert_eq!(response.status, 302, "the hop is surfaced, not taken");

// `/redirect` served no body and `/echo` would have echoed one, so the
// recorded request proves the target was never reached.
let recorded = server.last_request().expect("a request was recorded");
assert_eq!(recorded.path, "/redirect");
}

#[tokio::test]
async fn reqwest_http_capped_fetch_rejects_a_chunk_larger_than_the_cap() {
let server = MockServer::start();
Expand Down
6 changes: 6 additions & 0 deletions crates/desktop-seams/tests/mock_http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
//! header; records the request for assertions.
//! - `GET /teapot` — returns 418, to prove a non-2xx status is a response,
//! not a seam error.
//! - `GET /redirect` — 302 to `/echo`, to prove the seam follows no hop.
//! - `GET /stream/<n>` — `n` bytes with `Transfer-Encoding: chunked` and no
//! `Content-Length`, so a capped read has only the streaming drain to gate
//! on.
Expand All @@ -28,6 +29,8 @@ use std::time::Duration;
/// One request the server received, captured for test assertions.
#[derive(Clone)]
pub struct RecordedRequest {
/// Request target, so a test can prove which route was reached.
pub path: String,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
Expand Down Expand Up @@ -157,6 +160,7 @@ fn handle_conn(
body.truncate(content_length);

*last.lock().expect("lock") = Some(RecordedRequest {
path: path.clone(),
headers,
body: body.clone(),
});
Expand Down Expand Up @@ -186,6 +190,8 @@ fn handle_conn(
(200, "OK", vec![("x-echo", "yes")], body)
} else if path == "/teapot" {
(418, "I'm a teapot", Vec::new(), b"teapot".to_vec())
} else if path == "/redirect" {
(302, "Found", vec![("location", "/echo")], Vec::new())
} else {
(404, "Not Found", Vec::new(), Vec::new())
};
Expand Down
Loading