Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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}");
}