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
51 changes: 38 additions & 13 deletions rust/profiles/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,13 @@ pub struct TokenSession {
/// committed run is a suffix (REQ-COMMON-22). `None` for a public client,
/// whose request hides nothing and is revealed whole.
pub secret_field: Option<&'static str>,
/// Every header this request sends, lowercased as the wire spells them,
/// in no particular order. `content-length` is absent because its value
/// is the body's own count: the HTTP client appends it and the verifier
/// reads it rather than compares it.
pub request_headers: &'static [&'static str],
/// The same lines joined by CRLF, which is the shape a Platform
/// Verifier splits and matches as a set -- order is the prover's, the
/// set is the profile's.
pub request_header_block: &'static str,
/// The header lines a Platform Verifier requires, each exactly once with
/// its value: `host` and `content-type`, lowercased as the wire spells
/// them. Every other header is the runtime's own, save the names
/// `FORBIDDEN_REQUEST_HEADERS` lists. `content-length` is absent
/// because its value is the body's own count: the HTTP client appends
/// it and the verifier reads it rather than compares it.
pub required_headers: &'static [&'static str],
}

/// The identity session: the authenticated read that names the account.
Expand Down Expand Up @@ -123,8 +121,10 @@ pub const X: Profile = Profile {
request_line: "POST /2/oauth2/token ",
},
secret_field: None,
request_headers: &["host: api.x.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"],
request_header_block: "host: api.x.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close",
required_headers: &[
"host: api.x.com",
"content-type: application/x-www-form-urlencoded",
],
}),
identity: Some(IdentitySession {
session: Session {
Expand Down Expand Up @@ -154,8 +154,10 @@ pub const GITHUB: Profile = Profile {
request_line: "POST /login/oauth/access_token ",
},
secret_field: Some("client_secret"),
request_headers: &["host: github.com", "content-type: application/x-www-form-urlencoded", "accept: application/json", "connection: close"],
request_header_block: "host: github.com\r\ncontent-type: application/x-www-form-urlencoded\r\naccept: application/json\r\nconnection: close",
required_headers: &[
"host: github.com",
"content-type: application/x-www-form-urlencoded",
],
}),
identity: Some(IdentitySession {
session: Session {
Expand All @@ -182,6 +184,29 @@ pub fn launch(platform: &str) -> Option<&'static Profile> {
LAUNCH.iter().copied().find(|p| p.platform == platform)
}

/// Header names no notarized request may carry, compared by every
/// Platform Verifier with the name lowercased, its whitespace removed and
/// `_` read as `-`. Each changes what the platform does with the request
/// in a way no revealed byte shows: `authorization` which client it
/// authenticates, `content-encoding` and `transfer-encoding` which bytes
/// it parses, `cookie` which session it answers for, the three override
/// names which method it runs. The identity request is excepted from
/// `authorization` alone: its one such header, under any scheme, is what
/// REQ-COMMON-39 counts. On the token request the verifier further
/// requires each session's requiredHeaders, `host` and `content-type`,
/// reads `content-length`, and ignores every other header: one outside
/// both lists changes only what the platform answers, and a wrong answer
/// is a response the verifier cannot read.
pub const FORBIDDEN_REQUEST_HEADERS: &[&str] = &[
"authorization",
"content-encoding",
"cookie",
"transfer-encoding",
"x-http-method",
"x-http-method-override",
"x-method-override",
];

/// Governance-owned launch parameters, in seconds.
pub const MAX_FUTURE_ATTESTATION_SKEW_SECONDS: u64 = 300;
pub const PROOF_LIFETIME_SECONDS_X: u64 = 3600;
Expand Down
62 changes: 41 additions & 21 deletions rust/profiles/tests/vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! follows.

use libid_profiles::{
FORBIDDEN_REQUEST_HEADERS,
GITHUB,
GOOGLE,
LAUNCH,
Expand Down Expand Up @@ -155,33 +156,52 @@ fn the_launch_list_is_closed() {
}

#[test]
fn the_token_request_head_is_the_headers_beside_it() {
// Two representations of one agreement: the list a prover builds its
// request from, and the block the Platform Verifier matches against. They
// are generated together, and this is what says the two say the same thing.
fn the_required_headers_are_host_and_the_media_type() {
// The only header data a profile carries: the two lines a Platform
// Verifier holds the head to. `host` must name the pinned authority, or
// the profile contradicts itself; `content-type` selects the parser. No
// `content-length`: that value is the body's own and the verifier reads
// it off the transcript.
for profile in LAUNCH {
let Some(token) = profile.token else {
continue;
};
// The block is those same lines joined, which is the shape a verifier
// splits and matches as a set. It carries no `content-length`: that
// value is the body's own and the verifier reads it off the transcript.
assert_eq!(
token.request_header_block,
token.request_headers.join("\r\n")
);

assert!(
!token
.request_headers
.iter()
.any(|header| header.starts_with("content-length:")),
"the HTTP client appends the length; a listed one would move it"
);
let mut names: Vec<&str> = token
.required_headers
.iter()
.map(|line| line.split(':').next().unwrap())
.collect();
names.sort_unstable();
assert_eq!(names, ["content-type", "host"]);
let host = format!("host: {}", token.session.authority);
assert!(
token.request_headers.contains(&host.as_str()),
"the pinned `host` header and the pinned authority must name one server"
token.required_headers.contains(&host.as_str()),
"the required `host` header and the pinned authority must name one server"
);
}
}

#[test]
fn the_forbidden_names_are_lowercase_and_never_required() {
// The verifier lowercases what it reads and compares against this list
// as it is, so a name here in any other case would forbid nothing. And a
// profile that both requires a name and forbids it rejects every honest
// session.
for name in FORBIDDEN_REQUEST_HEADERS {
assert_eq!(*name, name.to_ascii_lowercase(), "{name}");
assert!(!name.is_empty());
}
for profile in LAUNCH {
let Some(token) = profile.token else {
continue;
};
for line in token.required_headers {
let name = line.split(':').next().unwrap();
assert!(
!FORBIDDEN_REQUEST_HEADERS.contains(&name),
"{} requires a header it forbids: {name}",
profile.platform
);
}
}
}
112 changes: 82 additions & 30 deletions rust/profiles/tests/wire.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
//! hyper writes a head this table admits.
//!
//! The profile fixes which headers a token request carries, not the order they
//! go in, so what a verifier checks is a set: every line the profile lists,
//! once each, nothing else, plus the `content-length` HTTP framing owns. This
//! asserts that hyper, given the profile's headers, writes exactly that -- the
//! request built from `request_headers` and driven through the real
//! `hyper::client::conn::http1` encoder over an in-memory duplex, so what is
//! compared is the bytes hyper actually wrote.
//! A verifier holds a token request's head to the profile's required headers,
//! each once with its value, refuses the forbidden names, and reads one
//! `content-length`; the rest of the head is the client's business. This
//! asserts that hyper, given the profile's required headers and the two a
//! runtime adds of its own, writes a head that passes -- the request driven
//! through the real `hyper::client::conn::http1` encoder over an in-memory
//! duplex, so what is checked is the bytes hyper actually wrote.
//!
//! Order is deliberately not asserted. Nothing promises where a client puts a
//! header, and the browser reaches the wire through tlsn's wasm prover, whose
//! `HttpRequest` holds them in a `HashMap` -- a test demanding an order would
//! pass here and fail there for a reason neither end could name.
//! Order is not asserted. Nothing promises where a client puts a header, and
//! the browser reaches the wire through tlsn's wasm prover, whose
//! `HttpRequest` holds them in a `HashMap`.
//!
//! What this still catches is a header hyper adds or drops on its own, and a
//! `content-length` it does not append for a known-length body. The lowercase
//! claim needs an input the profile cannot supply, which is the last case.
//! What this catches is hyper adding a forbidden header on its own, dropping
//! a required one, or not appending `content-length` for a known-length body.
//! The lowercase claim needs an input the profile cannot supply, which is the
//! last case.
//!
//! The GitHub exchange is the reason this exists. It runs in the deployment's
//! backend, which is the prover for that session and reaches the wire through
Expand All @@ -24,6 +24,7 @@
use hyper_util::rt::TokioIo;
use libid_profiles::{
TokenSession,
FORBIDDEN_REQUEST_HEADERS,
GITHUB,
X,
};
Expand All @@ -38,9 +39,11 @@ async fn head_hyper_writes(session: &TokenSession, body: &'static [u8]) -> Vec<u
.method(session.session.method)
.uri(session.session.path);

// In the profile's order, which decides nothing: the verifier matches the
// head as a set, and the order hyper writes is not asserted below.
for header in session.request_headers {
// The profile's required pair, then what the browser and the backend add
// of their own and the verifier does not compare. Order decides nothing
// and is not asserted below.
let own = ["accept: application/json", "connection: close"];
for header in session.required_headers.iter().chain(own.iter()) {
let (name, value) = header.split_once(": ").expect("`name: value`");
request = request.header(name, value);
}
Expand Down Expand Up @@ -88,21 +91,27 @@ fn header_lines(wire: &[u8]) -> Vec<String> {
}

fn assert_head_admits(session: &TokenSession, wire: &[u8], body_len: usize) {
let mut written = header_lines(wire);
written.sort();

let mut expected: Vec<String> = session
.request_headers
let written = header_lines(wire);
let name_of = |line: &String| line.split(':').next().unwrap().to_ascii_lowercase();

for required in session.required_headers {
let count = written.iter().filter(|line| *line == required).count();
assert_eq!(
count, 1,
"required header not written exactly once: {required}"
);
}
for line in &written {
assert!(
!FORBIDDEN_REQUEST_HEADERS.contains(&name_of(line).as_str()),
"hyper wrote a forbidden header: {line}"
);
}
let lengths: Vec<&String> = written
.iter()
.map(|line| (*line).to_owned())
.filter(|line| name_of(line) == "content-length")
.collect();
expected.push(format!("content-length: {body_len}"));
expected.sort();

assert_eq!(
written, expected,
"hyper wrote a head the profile does not admit"
);
assert_eq!(lengths, [&format!("content-length: {body_len}")]);
}

#[tokio::test]
Expand Down Expand Up @@ -142,6 +151,49 @@ async fn the_declared_length_is_the_body_and_moves_with_it() {
}
}

#[tokio::test]
async fn a_length_the_builder_sets_itself_is_written_once() {
// X's browser builder sets `content-length` itself, third among five, and
// hyper is still the encoder underneath tlsn's prover. A verifier requires
// exactly one, so what matters is that hyper keeps the caller's rather than
// adding its own beside it -- and keeps the value.
let session = X.token.expect("x notarizes a token session");
let body: &'static [u8] = b"grant_type=authorization_code&client_id=abc&code=xyz";
let (client, mut server) = tokio::io::duplex(1 << 12);
let (mut sender, connection) =
hyper::client::conn::http1::handshake(TokioIo::new(client))
.await
.expect("handshake");
tokio::spawn(connection);
let mut request = hyper::Request::builder()
.method(session.session.method)
.uri(session.session.path);
for header in [
"host: api.x.com",
"content-type: application/x-www-form-urlencoded",
"content-length: 52",
"accept: application/json",
"connection: close",
] {
let (name, value) = header.split_once(": ").expect("`name: value`");
request = request.header(name, value);
}
let request = request
.body(http_body_util::Full::new(hyper::body::Bytes::from(body)))
.expect("valid request");
let sending = tokio::spawn(async move { sender.send_request(request).await });
let mut wire = Vec::new();
let mut buf = [0u8; 1024];
while !wire.windows(4).any(|w| w == b"\r\n\r\n") {
let read = server.read(&mut buf).await.expect("read");
assert!(read > 0, "the connection closed before the request head");
wire.extend_from_slice(&buf[..read]);
}
drop(server);
let _ = sending.await;
assert_head_admits(&session, &wire, body.len());
}

#[tokio::test]
async fn hyper_writes_field_names_in_lower_case() {
// The generator lays the head out in lowercase and its `validate` refuses
Expand Down
Loading
Loading