crates/monetize-front/tests/attach_security.rs
//! **The front's public attach doors, their rules in one place** (P5-SEC,
//! `security.rule-tests`).
//!
//! `POST /attach/enrol` and `POST /attach/installed` are open to the internet on the ONE box
//! (the admin edge and door call [`enrol_answer`] and [`installed_answer`]; [`routes`] is the
//! same pair). Each test names one rule and was seen to fail against a deliberately broken check
//! before it passed (the breaks are in /work/handback/p5-sec.md):
//!
//! | rule | the check | refused as |
//! |---|---|---|
//! | an enrolment with a token this central never minted is refused (unauthenticated) | plugin-door-wire `redeem` | 403 `attach-token-unknown` |
//! | an install notice for an estate that never enrolled is refused (unauthenticated) | `installed_answer` | 403 `attach-not-enrolled` |
//! | a token minted for one estate does not enrol another (wrong tenant) | plugin-door-wire `redeem` | 403 `attach-token-other-estate` |
//! | an enrolled estate's notice for another product is refused (wrong tenant) | `installed_answer` | 403 `attach-other-product` |
//! | a notice not signed by the ENROLLED owner key is refused (forged) | `installed_answer` | 403 `attach-signature` |
//! | an enrolment altered after its owner signed it is refused (forged) | plugin-door-wire `EnrolRequest::verify` | 403 `attach-signature` |
//! | a token is spent once, and a notice is taken once (replay) | `redeem`; `installed_answer` | 403 `attach-token-used`; 403 `attach-replayed` |
//! | a body over [`ATTACH_BODY_MAX`] is refused before it is parsed (oversized) | `enrol_answer`, `installed_answer` | 413 `attach-too-large` |
use std::time::Duration;
use axum::body::Body;
use axum::http::Request;
use monetize_front::attach::{mint_token, routes, Central, ATTACH_BODY_MAX};
use plugin_door_wire::attach::{EnrolRequest, InstalledNotice, INSTALLED_PATH, VERSION};
use tower::ServiceExt as _;
fn key(seed: u8) -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]))
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
fn central(dir: &tempfile::TempDir) -> Central {
Central {
root: dir.path().to_path_buf(),
plugin_door: "monetize.ignalina.dk:443".into(),
}
}
async fn post(c: &Central, path: &str, body: impl Into<Body>) -> (u16, String) {
let res = routes(c.clone())
.oneshot(
Request::post(path)
.header("content-type", "application/json")
.body(body.into())
.unwrap(),
)
.await
.unwrap();
let st = res.status().as_u16();
let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
.await
.unwrap();
(st, String::from_utf8_lossy(&bytes).to_string())
}
fn enrol(token: String, estate: &str, owner: &ssh_key::PrivateKey, nonce: &str) -> EnrolRequest {
let (csr, _) = nordisk_pairing::mtls::box_request().unwrap();
EnrolRequest {
v: VERSION,
token,
estate: estate.into(),
product: "gunnar".into(),
box_name: "main".into(),
csr_pem: csr,
endpoint: "gunnar.acme.example:443".into(),
owner_key: owner.public_key().to_openssh().unwrap(),
estate_ca_pem: String::new(),
nonce: nonce.into(),
at_unix_ms: now_ms(),
owner_sig: String::new(),
}
.sign(owner)
.unwrap()
}
fn json<T: serde::Serialize>(t: &T) -> String {
serde_json::to_string(t).unwrap()
}
fn notice(product: &str, at: i64, by: &ssh_key::PrivateKey) -> String {
let n = InstalledNotice {
v: VERSION,
estate: "acme".into(),
product: product.into(),
roles: vec!["main".into()],
nonce: format!("0123456789abcdef-{at}"),
at_unix_ms: at,
ticket_key: String::new(),
fleet_keys: Default::default(),
owner_sig: String::new(),
};
json(&n.sign(by).unwrap())
}
/// `acme` enrolled under `owner`.
async fn enrolled(c: &Central, owner: &ssh_key::PrivateKey) {
let t = mint_token(c, "acme", "gunnar", Duration::from_secs(3600)).unwrap();
let (st, text) = post(
c,
"/attach/enrol",
json(&enrol(t, "acme", owner, "0123456789abcdef-e")),
)
.await;
assert_eq!(st, 200, "the control enrolment: {text}");
}
/// RULE: no credential, no door: a token this central never minted, and a notice for an
/// estate that never enrolled.
#[tokio::test]
async fn an_unauthenticated_caller_is_refused() {
let dir = tempfile::tempdir().unwrap();
let c = central(&dir);
let owner = key(4);
let real = mint_token(&c, "acme", "gunnar", Duration::from_secs(3600)).unwrap();
// The right shape, the real id, a secret that is not the minted one.
let (id, _) = real.rsplit_once('.').unwrap();
let forged = format!(
"{id}.{}",
"0".repeat(real.rsplit_once('.').unwrap().1.len())
);
let (st, text) = post(
&c,
"/attach/enrol",
json(&enrol(forged, "acme", &owner, "0123456789abcdef-1")),
)
.await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-token-unknown"), "{text}");
let (st, text) = post(&c, INSTALLED_PATH, notice("gunnar", now_ms(), &owner)).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-not-enrolled"), "{text}");
// The real token still enrols: the forgery spent nothing.
let (st, text) = post(
&c,
"/attach/enrol",
json(&enrol(real, "acme", &owner, "0123456789abcdef-2")),
)
.await;
assert_eq!(st, 200, "{text}");
}
/// RULE: a token minted for one estate does not enrol another, and an estate's notice speaks
/// only for the product it enrolled as.
#[tokio::test]
async fn a_wrong_tenant_is_refused() {
let dir = tempfile::tempdir().unwrap();
let c = central(&dir);
let owner = key(4);
let t = mint_token(&c, "acme", "gunnar", Duration::from_secs(3600)).unwrap();
let (st, text) = post(
&c,
"/attach/enrol",
json(&enrol(t, "other", &owner, "0123456789abcdef-1")),
)
.await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-token-other-estate"), "{text}");
assert!(
monetize_front::attach::enrolled(&c, "other")
.unwrap()
.is_none(),
"nothing recorded for `other`"
);
enrolled(&c, &owner).await;
let (st, text) = post(&c, INSTALLED_PATH, notice("holger", now_ms(), &owner)).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-other-product"), "{text}");
assert!(
monetize_front::attach::enrolled(&c, "acme")
.unwrap()
.unwrap()
.roles
.is_empty(),
"no box opened"
);
}
/// RULE: a notice not signed by the key recorded at enrolment is refused, and an enrolment
/// changed after its owner signed it is refused.
#[tokio::test]
async fn a_forged_signature_is_refused() {
let dir = tempfile::tempdir().unwrap();
let c = central(&dir);
let owner = key(4);
enrolled(&c, &owner).await;
let (st, text) = post(&c, INSTALLED_PATH, notice("gunnar", now_ms(), &key(9))).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-signature"), "{text}");
assert!(
monetize_front::attach::enrolled(&c, "acme")
.unwrap()
.unwrap()
.roles
.is_empty(),
"no box opened"
);
let t = mint_token(&c, "beta", "gunnar", Duration::from_secs(3600)).unwrap();
let mut req = enrol(t, "beta", &owner, "0123456789abcdef-3");
req.endpoint = "evil.example:443".into();
let (st, text) = post(&c, "/attach/enrol", json(&req)).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-signature"), "{text}");
}
/// RULE: a token is spent once, and an install notice is taken once.
#[tokio::test]
async fn a_replay_is_refused() {
let dir = tempfile::tempdir().unwrap();
let c = central(&dir);
let owner = key(4);
let t = mint_token(&c, "acme", "gunnar", Duration::from_secs(3600)).unwrap();
let body = json(&enrol(t, "acme", &owner, "0123456789abcdef-1"));
let (st, text) = post(&c, "/attach/enrol", body.clone()).await;
assert_eq!(st, 200, "{text}");
let (st, text) = post(&c, "/attach/enrol", body).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-token-used"), "{text}");
let n = notice("gunnar", now_ms(), &owner);
let (st, text) = post(&c, INSTALLED_PATH, n.clone()).await;
assert_eq!(st, 200, "{text}");
let (st, text) = post(&c, INSTALLED_PATH, n).await;
assert_eq!(st, 403, "{text}");
assert!(text.contains("attach-replayed"), "{text}");
}
/// RULE: a body over [`ATTACH_BODY_MAX`] is refused 413 by name before it is parsed, on both
/// doors; a body just at it is parsed (and refused as no request, 400).
#[tokio::test]
async fn an_oversized_body_is_refused_before_it_is_parsed() {
let dir = tempfile::tempdir().unwrap();
let c = central(&dir);
for path in ["/attach/enrol", INSTALLED_PATH] {
let (st, text) = post(&c, path, vec![b' '; ATTACH_BODY_MAX + 1]).await;
assert_eq!(st, 413, "{path}: {text}");
assert!(text.contains("attach-too-large"), "{path}: {text}");
let (st, text) = post(&c, path, vec![b' '; ATTACH_BODY_MAX]).await;
assert_eq!(st, 400, "{path}, at the cap, is parsed: {text}");
}
// A real enrolment is far below the cap.
let t = mint_token(&c, "acme", "gunnar", Duration::from_secs(3600)).unwrap();
assert!(json(&enrol(t, "acme", &key(4), "0123456789abcdef-1")).len() * 8 < ATTACH_BODY_MAX);
}