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

nordisk/monetize

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