crates/monetize-admin/tests/door_security.rs
//! **The operator's door and the public edge, their rules in one place** (P5-SEC,
//! `security.rule-tests`).
//!
//! The door is mutual TLS under the box's installer CA, and only the operator's certificate
//! acts; the edge is the box's public face, where an estate enrols. 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 |
//! |---|---|---|
//! | no certificate from the installer CA, no door (unauthenticated) | the TLS acceptor | the handshake, no HTTP at all |
//! | a box's own certificate, or a stranger's of the same CA, does not act (wrong box) | the operator CN, per connection | 403 `[not-the-operator]`, nothing written |
//! | a copy signed under another signing root does not restore (forged) | `offsite::verify` (import under the bundled root) | 422 `[offsite-verify]`, nothing staged |
//! | a restore goes onto a fresh box once (replay) | `restore::stage` (`not_fresh`) | 422 `[restore-not-fresh]` |
//! | a body over the door's [`BODY_LIMIT`] is refused, and the edge's enrolment reads at most the front's cap (oversized) | the door's `DefaultBodyLimit`; `attach::too_large` | 413 |
//!
//! Replay of a door call is the operator's by design (no nonce on an mTLS call); the
//! enrolment token's once-only rule is held in monetize-front's attach_security.rs.
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use monetize::Signer;
use monetize_admin::door::BODY_LIMIT;
use monetize_admin::{offsite, restore};
use monetize_front::attach::ATTACH_BODY_MAX;
use monetize_front::intake;
use nordisk_pairing::mtls::{self, InstallerCa, Issued};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
struct Door {
addr: SocketAddr,
ca: InstallerCa,
operator: Issued,
box_identity: Issued,
stranger: Issued,
dir: tempfile::TempDir,
}
impl Door {
async fn up() -> Door {
let ca = InstallerCa::mint("monetize-admin", Duration::from_secs(3600)).unwrap();
let box_identity = ca.issue_bootstrap(Duration::from_secs(3600)).unwrap();
let operator = ca.issue_installer(Duration::from_secs(3600)).unwrap();
let (csr, key) = mtls::box_request().unwrap();
let stranger = Issued {
cert_pem: ca
.sign_box(&csr, "admin", Duration::from_secs(3600))
.unwrap(),
key_pem: key,
};
let dir = tempfile::tempdir().unwrap();
intake::prepare(dir.path()).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let id = box_identity.clone();
tokio::spawn(monetize_admin::door::serve(
listener,
ca.cert_pem.clone(),
Arc::new(move || id.clone()),
dir.path().to_path_buf(),
));
Door {
addr,
ca,
operator,
box_identity,
stranger,
dir,
}
}
fn root(&self) -> &Path {
self.dir.path()
}
/// One request as `me`; `None` when the door never answered in HTTP (the handshake was
/// refused). A server that answers before the whole body is read is still heard.
async fn send(
&self,
me: &Issued,
method: &str,
path: &str,
body: &[u8],
) -> Option<(u16, String)> {
send_to(self.addr, &self.ca.cert_pem, me, method, path, body)
.await
.unwrap_or_else(|e| panic!("{e}"))
}
}
/// **How long one request at the door may take** (RA1-11): connect, handshake, the body and
/// the answer, together. The oversized body (64 MiB) takes well under a second here.
const DEADLINE: Duration = Duration::from_secs(30);
/// Any other awaited step of a test (the edge's in-process calls), under the same deadline.
async fn bounded<F: std::future::Future>(what: &str, f: F) -> F::Output {
tokio::time::timeout(DEADLINE, f)
.await
.unwrap_or_else(|_| panic!("{what}: not done within {DEADLINE:?}"))
}
/// One request at `addr` as `me`: `Ok(None)` when the door never answered in HTTP, `Err` when
/// it did not answer within [`DEADLINE`].
async fn send_to(
addr: SocketAddr,
ca_pem: &str,
me: &Issued,
method: &str,
path: &str,
body: &[u8],
) -> Result<Option<(u16, String)>, String> {
tokio::time::timeout(DEADLINE, send_once(addr, ca_pem, me, method, path, body))
.await
.map_err(|_| format!("{method} {path}: no answer within {DEADLINE:?}"))
}
async fn send_once(
addr: SocketAddr,
ca_pem: &str,
me: &Issued,
method: &str,
path: &str,
body: &[u8],
) -> Option<(u16, String)> {
{
let cfg = mtls::client_config(ca_pem, me).ok()?;
let tcp = tokio::net::TcpStream::connect(addr).await.ok()?;
let mut tls = tokio_rustls::TlsConnector::from(cfg)
.connect(mtls::vm_name(), tcp)
.await
.ok()?;
let head = format!("{method} {path} HTTP/1.1\r\nHost: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", mtls::VM_SERVER_NAME, body.len());
tls.write_all(head.as_bytes()).await.ok()?;
let _ = tls.write_all(body).await;
// RA1-11, the 27-minute hang: write_all can return with the body's last TLS records
// still in rustls's buffer. Unflushed, the door waits for the byte past its limit (the
// one that makes it refuse) while this side waits for the refusal.
let _ = tls.flush().await;
let mut out = Vec::new();
let _ = tls.read_to_end(&mut out).await;
let t = String::from_utf8_lossy(&out).to_string();
let status = t.split_whitespace().nth(1).and_then(|s| s.parse().ok())?;
Some((
status,
t.split_once("\r\n\r\n")
.map(|(_, b)| b.to_string())
.unwrap_or_default(),
))
}
}
/// RULE (RA1-11): a door that takes the request and never answers is a failure within the
/// deadline, never a test that hangs (door_security once sat 27 minutes at 0 % CPU).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_door_that_never_answers_fails_within_the_deadline() {
let ca = InstallerCa::mint("silent", Duration::from_secs(3600)).unwrap();
let served = ca.issue_bootstrap(Duration::from_secs(3600)).unwrap();
let operator = ca.issue_installer(Duration::from_secs(3600)).unwrap();
let cfg = mtls::server_config(&ca.cert_pem, &served).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// The handshake, then silence: the request is never read and never answered.
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((tcp, _)) = listener.accept().await {
if let Ok(tls) = tokio_rustls::TlsAcceptor::from(cfg.clone())
.accept(tcp)
.await
{
held.push(tls);
}
}
});
let t = std::time::Instant::now();
let r = tokio::time::timeout(
DEADLINE + Duration::from_secs(15),
send_to(addr, &ca.cert_pem, &operator, "PUT", "/feed", &[b' '; 1024]),
)
.await;
let r = r.unwrap_or_else(|_| {
panic!(
"send_to has no deadline: still waiting after {:?}",
t.elapsed()
)
});
let e = r.expect_err("no answer is a failure");
assert!(
e.contains("PUT /feed") && e.contains("no answer within"),
"{e}"
);
}
/// RULE: a client without a certificate from the box's installer CA gets no HTTP answer at all.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_client_from_another_ca_gets_no_door() {
let door = Door::up().await;
let other = InstallerCa::mint("someone-else", Duration::from_secs(3600)).unwrap();
let forged_operator = other.issue_installer(Duration::from_secs(3600)).unwrap();
assert_eq!(
door.send(&forged_operator, "POST", "/passkeys/reset", b"")
.await,
None,
"the handshake is refused"
);
assert!(
!door.root().join(monetize_admin::solo::RESET_REL).exists(),
"nothing was done"
);
// The control: the operator is answered.
let (st, body) = door
.send(&door.operator, "POST", "/passkeys/reset", b"")
.await
.expect("the operator is heard");
assert_eq!(st, 202, "{body}");
}
/// RULE: only the operator's certificate acts. The box's own, and a box certificate named
/// `admin` from the same CA, are refused by name at every operator door, and nothing is
/// written.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_box_or_a_stranger_of_the_same_ca_is_not_the_operator() {
let door = Door::up().await;
let doors: [(&str, &str, &[u8]); 5] = [
("PUT", "/feed", b"{}"),
("POST", "/passkeys/reset", b""),
(
"POST",
"/attach/token",
br#"{"estate":"acme","product":"gunnar"}"#,
),
("POST", "/restore", b"{}"),
("POST", "/self/update", b"{}"),
];
for who in [&door.box_identity, &door.stranger] {
for (m, path, body) in doors {
let (st, text) = door
.send(who, m, path, body)
.await
.expect("answered in HTTP");
assert_eq!(st, 403, "{m} {path}: {text}");
assert!(text.contains("[not-the-operator]"), "{m} {path}: {text}");
}
}
assert!(!door
.root()
.join(monetize_admin::solo::DOOR_FEED_REL)
.exists());
assert!(!door.root().join(monetize_admin::solo::RESET_REL).exists());
assert!(
!door
.root()
.join(monetize_front::attach::TOKENS_REL)
.exists(),
"no token minted"
);
assert!(!door.root().join(restore::STAGED_REL).exists());
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// An opened off-box copy of a box with real history, as the door's `POST /restore` body.
fn a_copy(scratch: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
use monetize::testing::{purchase, Answer, MockCloud, MockPay, MockProduct, NOW};
use monetize_server::backup as mb;
let cfg = monetize_server::config::Config {
data_dir: scratch.join(intake::DATA_REL),
..Default::default()
};
std::fs::create_dir_all(cfg.data_dir.join("custody")).unwrap();
let signer = Signer::from_bytes(&[0x5A; 32]);
signer.write_to(&cfg.signing_key_path()).unwrap();
let ledger = monetize::Ledger::open(&cfg.ledger_path()).unwrap();
let usage = monetize_server::state::UsageStore::open(&cfg.usage_path()).unwrap();
let signups = monetize_server::signup::SignupStore::open(&cfg.signup_path()).unwrap();
let reconcile =
monetize_server::reconcile::ReconcileStore::open(&cfg.reconcile_path()).unwrap();
let nonpay = monetize_server::nonpay::NonpayJournal::open(cfg.nonpay_path()).unwrap();
let (pay, cloud, product) = (MockPay::new(), MockCloud::new(), MockProduct::gunnar());
cloud.answer_all(Answer::Provisioned);
monetize::Transaction {
pay: monetize::pay::Vendor::of(&pay),
cloud: &cloud,
product: product.served(),
ledger: &ledger,
signer: &signer,
now_unix_ms: NOW,
}
.run(&purchase("alice"))
.unwrap();
let stores = mb::Stores {
ledger: &ledger,
usage: &usage,
signups: &signups,
reconcile: &reconcile,
nonpay: &nonpay,
};
mb::export(
&scratch.join(monetize_front::ledger_backup::BACKUP_REL),
&stores,
&hex(&signer.public_key()),
)
.unwrap();
std::fs::write(cfg.data_dir.join("custody/kek"), [0xC3; 32]).unwrap();
offsite::pack(scratch).unwrap()
}
fn body_of(entries: &std::collections::BTreeMap<String, Vec<u8>>) -> Vec<u8> {
serde_json::to_vec(
&entries
.iter()
.map(|(k, v)| (k.clone(), hex(v)))
.collect::<std::collections::BTreeMap<_, _>>(),
)
.unwrap()
}
/// RULE: a copy whose ledger was signed under another signing root does not restore, even
/// through the operator's own door; nothing is staged.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_copy_under_a_foreign_signing_root_does_not_restore() {
let door = Door::up().await;
let scratch = tempfile::tempdir().unwrap();
let mut entries = a_copy(scratch.path());
let foreign = scratch.path().join("foreign.key");
Signer::from_bytes(&[1; 32]).write_to(&foreign).unwrap();
entries.insert("data/signing.key".into(), std::fs::read(&foreign).unwrap());
let (st, text) = door
.send(&door.operator, "POST", "/restore", &body_of(&entries))
.await
.expect("answered");
assert_eq!(st, 422, "{text}");
assert!(text.contains("[offsite-verify]"), "{text}");
assert!(
!door.root().join(restore::STAGED_REL).exists(),
"nothing staged"
);
}
/// RULE: a restore goes onto a fresh box, once: after a copy was applied, the same copy again
/// is refused by name.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_restore_is_not_replayed_onto_a_restored_box() {
let door = Door::up().await;
let scratch = tempfile::tempdir().unwrap();
let body = body_of(&a_copy(scratch.path()));
let (st, text) = door
.send(&door.operator, "POST", "/restore", &body)
.await
.expect("answered");
assert_eq!(
st, 200,
"the first restore of a fresh box is staged: {text}"
);
restore::apply_staged(door.root())
.unwrap()
.expect("applied at the next start");
let (st, text) = door
.send(&door.operator, "POST", "/restore", &body)
.await
.expect("answered");
assert_eq!(st, 422, "{text}");
assert!(text.contains("[restore-not-fresh]"), "{text}");
assert!(
!door.root().join(restore::STAGED_REL).exists(),
"nothing staged the second time"
);
}
/// RULE: the door reads at most [`BODY_LIMIT`]; the edge's public enrolment reads at most the
/// front's [`ATTACH_BODY_MAX`], before any parsing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_oversized_body_is_refused_at_the_door_and_the_edge() {
let door = Door::up().await;
let (st, text) = door
.send(&door.operator, "PUT", "/feed", &vec![b' '; BODY_LIMIT + 1])
.await
.expect("answered");
assert_eq!(st, 413, "{text}");
use tower::ServiceExt as _;
monetize_admin::solo::UiSettings {
rp_id: "monetize.ignalina.dk".into(),
admin_token: None,
}
.write(door.root())
.unwrap();
let edge = monetize_admin::edge::router(
"127.0.0.1:1".parse().unwrap(),
"127.0.0.1:1".parse().unwrap(),
door.root().to_path_buf(),
);
let post = |n: usize| {
axum::http::Request::post(monetize_admin::edge::ENROL_PATH)
.header("content-type", "application/json")
.body(axum::body::Body::from(vec![b' '; n]))
.unwrap()
};
let res = bounded(
"the edge, over the cap",
edge.clone().oneshot(post(ATTACH_BODY_MAX + 1)),
)
.await
.unwrap();
assert_eq!(res.status(), 413);
let text = String::from_utf8_lossy(
&bounded(
"the edge's answer",
axum::body::to_bytes(res.into_body(), 1 << 20),
)
.await
.unwrap(),
)
.to_string();
assert!(text.contains("attach-too-large"), "{text}");
// At the cap it is read: no request, by name.
let res = bounded("the edge, at the cap", edge.oneshot(post(ATTACH_BODY_MAX)))
.await
.unwrap();
assert_eq!(res.status(), 400);
// The door's own enrolment is the same answer.
let (st, text) = door
.send(
&door.stranger,
"POST",
monetize_admin::edge::ENROL_PATH,
&vec![b' '; ATTACH_BODY_MAX + 1],
)
.await
.expect("answered");
assert_eq!(st, 413, "{text}");
}