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

nordisk/monetize

crates/monetize-server/tests/door_security.rs

//! **The server's door rules, one place, over the wire** (P5-SEC, A22 §3.3 `security.rule-tests`).
//!
//! Each test names ONE rule a hostile caller meets at monetize-server's door, and was seen to
//! fail against a deliberately broken check before it passed (the breaks are listed in
//! /work/handback/p5-sec.md):
//!
//! | rule | the check | what the caller gets |
//! |---|---|---|
//! | no bearer, or a wrong one, opens no service | `control::interceptor` | `Unauthenticated` |
//! | a site cannot name another site's account | `site_ticket::tenant_on` | `PermissionDenied`, nothing booked |
//! | a ticket signed by another key, or altered after signing, is refused | `site_ticket::may_act` → `verify_ticket` | `PermissionDenied`, nothing booked |
//! | a ticket is spent once | `site_ticket::may_act` → `Registry::spend_site_nonce` | `PermissionDenied` [`SPENT`] |
//! | an SSHSIG control credential from a stranger, or used twice, is refused | `control` (sshsig + `ReplayGuard`) | `Unauthenticated` |
//! | a callback body over [`MAX_CALLBACK_BYTES`] is refused before any parser runs | `callback::handle` | 413, no refusal row |
//!
//! The real router (`TestServer`), real tonic clients, no product guest (no `build-guests`).

use std::collections::BTreeMap;
use std::sync::Arc;

use ed25519_dalek::{Signer as _, SigningKey};
use monetize::embed::signing::base64_encode;
use monetize::embed::ticket::{ticket_message, ActorTicket};
use monetize::embed::PURPOSE_ORDER;
use monetize::testing::{Answer, MockCloud, NOW};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Market, Money, PayError, RawCallback, Settlement};
use monetize_proto::control_auth::{self, sshsig, METADATA_KEY};
use monetize_proto::ledger_client::LedgerClient;
use monetize_proto::products_client::ProductsClient;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::query_ledger_request::By;
use monetize_proto::{self as pb, ListProductsRequest};
use monetize_server::callback::MAX_CALLBACK_BYTES;
use monetize_server::config::EstateConfig;
use monetize_server::registry::Registry;
use monetize_server::site_ticket::SPENT;
use monetize_server::state::AppState;
use monetize_server::testing::estate::FakeEstate;
use monetize_server::testing::products::TestProduct;
use monetize_server::testing::{http_post, CallbackPay, TestServer};
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};

const SERVICE: &str = "test-bearer-token-of-sufficient-length";
const HOME: &str = "gunnar-rs";
const TEST2: &str = "test2-gunnar-rs";
const HOME_BEARER: &str = "estate-bearer-of-gunnar-rs-0123456789";
const TEST2_BEARER: &str = "estate-bearer-of-test2-0123456789abcd";

/// A vendor instance that only says who it is (and opens a page).
struct Named(&'static str);

#[allow(dead_code)]
impl Named {
    pub fn vendor(&self) -> &'static str {
        self.0
    }
    pub fn markets(&self) -> &[Market] {
        &[Market::Se]
    }
    pub fn reserve(&self, r: &ChargeRequest) -> Result<ChargeHandle, PayError> {
        Ok(ChargeHandle { vendor: self.0.into(), id: format!("cs_{}", r.reference.len()) })
    }
    pub fn draw(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn unlock(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Released)
    }
    pub fn charge_status(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
        Ok(ChargeStatus::Reserved)
    }
    pub fn parse_callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
        Err(PayError::Ignored(format!("seen by {}", self.0)))
    }
    pub fn give_back(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
        Err(PayError::Unavailable("not here".into()))
    }
    pub fn settled(&self, _: &ChargeHandle) -> Option<Settlement> {
        None
    }
    pub fn redirect(&self, _: &ChargeHandle) -> Option<String> {
        None
    }
    pub fn start_signup(&self, _: &monetize_pay::signup::SignupOrder) -> Result<Option<monetize_pay::signup::SignupCheckout>, PayError> {
        Ok(None)
    }
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Named {
    fn manifest(&self) -> monetize_pay::payment::PayManifest {
        monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
    }
    fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
        Ok(match charge {
            monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
            monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
            monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
            monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
        })
    }
    fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
        Ok(match of {
            monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
            monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
            monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
            monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
        })
    }
    fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.draw(h)
    }
    fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.unlock(h)
    }
    fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
        self.give_back(h, amount)
    }
    fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
        self.parse_callback(raw)
    }
    fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
        self.settled(h)
    }
    fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
        Err(monetize_pay::reconcile::cannot_be_asked())
    }
    fn livemode(&self) -> Option<bool> {
        None
    }
}

fn home_key() -> SigningKey {
    SigningKey::from_bytes(&[21u8; 32])
}
fn test2_key() -> SigningKey {
    SigningKey::from_bytes(&[22u8; 32])
}

fn hex(k: &SigningKey) -> String {
    k.verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
}

fn caps() -> BTreeMap<String, u64> {
    [("pack_bytes".to_string(), 10u64 << 30)].into()
}

fn signed_ticket(by: &SigningKey, tenant: &str, nonce: &str) -> ActorTicket {
    let mut t = ActorTicket::unsigned("gunnar", "gunnar", tenant, PURPOSE_ORDER, nonce, NOW as i64, 60_000).covering(caps());
    t.signature = base64_encode(&by.sign(&ticket_message(&t)).to_bytes());
    t
}

fn order_ticket(by: &SigningKey, tenant: &str, nonce: &str) -> Vec<u8> {
    serde_json::to_vec(&signed_ticket(by, tenant, nonce)).unwrap()
}

/// Two sites of one product, each with its own estate bearer and its own appliance key.
struct Sites {
    state: Arc<AppState>,
    /// Held for the sites' life: dropped, the server stops.
    _server: TestServer,
    channel: Channel,
}

impl Sites {
    async fn up() -> Sites {
        let cloud = MockCloud::new();
        cloud.answer_all(Answer::Provisioned);
        let site = |vendor: &str, site: &str, key: &SigningKey| EstateConfig {
            product: "gunnar".into(),
            vendor: vendor.into(),
            owner: "ignalina".into(),
            site: site.into(),
            appliance_key: hex(key),
            ..Default::default()
        };
        let reg = Registry::new()
            .pulled_product(TestProduct::gunnar(), HOME)
            .vendor(Named("stripe/gunnar"))
            .vendor(Named("stripe/gunnar-test2"))
            .route("gunnar", "stripe/gunnar")
            .home("gunnar", HOME)
            .estate(HOME, site("stripe/gunnar", "gunnar.rs", &home_key()))
            .estate(TEST2, site("stripe/gunnar-test2", "test2.gunnar.rs", &test2_key()))
            .cloud(cloud);
        let mut state = AppState::in_memory(reg);
        *state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
        state.clock = Box::new(|| NOW);
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(HOME_BEARER), HOME);
        state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(TEST2_BEARER), TEST2);
        // Each site's own main: room bought as iron, as the old double answered.
        for estate in [HOME, TEST2] {
            FakeEstate::declare(&state, "gunnar", estate).buys_iron(&["pack_bytes"]);
        }
        let state = Arc::new(state);
        let server = TestServer::spawn(state.clone(), SERVICE).await;
        let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
        Sites { state, _server: server, channel }
    }

    async fn start(&self, bearer: &str, tenant: &str, ticket: Vec<u8>) -> Result<pb::StartPurchaseResponse, tonic::Status> {
        let mut r = Request::new(pb::StartPurchaseRequest {
            product: "gunnar".into(),
            tenant: tenant.into(),
            caps: caps(),
            actor_ticket: ticket,
            ..Default::default()
        });
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {bearer}")).unwrap());
        PurchaseClient::new(self.channel.clone()).start(r).await.map(|r| r.into_inner())
    }

    /// Every ledger row there is: a refused door books nothing.
    async fn rows(&self) -> usize {
        let mut r = Request::new(pb::QueryLedgerRequest { by: Some(By::Recent(1000)) });
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {SERVICE}")).unwrap());
        LedgerClient::new(self.channel.clone()).query(r).await.expect("the service bearer reads the ledger").into_inner().rows.len()
    }
}

fn query(bearer: Option<&str>) -> Request<pb::QueryLedgerRequest> {
    let mut r = Request::new(pb::QueryLedgerRequest { by: Some(By::Recent(10)) });
    if let Some(b) = bearer {
        r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {b}")).unwrap());
    }
    r
}

/// RULE: no bearer, or a wrong one, opens no service. The ledger sits behind
/// `control::interceptor` alone, so nothing else could be what refused it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_unauthenticated_caller_is_refused_at_every_service() {
    let rig = Sites::up().await;
    let mut ledger = LedgerClient::new(rig.channel.clone());
    for (who, bearer) in [("no bearer", None), ("a wrong bearer", Some("wrong-bearer-of-sufficient-length-000")), ("an empty bearer", Some(""))] {
        let e = ledger.query(query(bearer)).await.expect_err(who);
        assert_eq!(e.code(), Code::Unauthenticated, "{who}: {e:?}");
    }
    let e = ProductsClient::new(rig.channel.clone()).list(Request::new(ListProductsRequest {})).await.expect_err("products, no bearer");
    assert_eq!(e.code(), Code::Unauthenticated, "{e:?}");
    // The right bearer is served: the refusals above are the bearer's, not a dead server's.
    ledger.query(query(Some(SERVICE))).await.expect("the service bearer is served");
}

/// RULE: a site's bearer cannot name another site's account (a wrong tenant), and
/// nothing is booked for it anywhere.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_wrong_tenant_is_refused_and_booked_nowhere() {
    let rig = Sites::up().await;
    let e = rig.start(TEST2_BEARER, "@gunnar-rs/nina", order_ticket(&test2_key(), "nina", "wt-1")).await.expect_err("not test2's account");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    assert!(e.message().contains("is not an account of this site"), "{}", e.message());
    let e = rig.start(HOME_BEARER, "@test2-gunnar-rs/nina", order_ticket(&home_key(), "nina", "wt-2")).await.expect_err("not gunnar.rs's account");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    assert_eq!(rig.rows().await, 0, "a refused tenant books nothing");
    // The site's own account, under the same bearer and key, is served.
    rig.start(TEST2_BEARER, "nina", order_ticket(&test2_key(), "nina", "wt-3")).await.expect("test2's own account");
}

/// RULE: a ticket signed by another appliance's key, or altered after it was signed, is
/// refused; nothing is booked and its nonce is not spent.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_forged_ticket_is_refused() {
    let rig = Sites::up().await;
    let e = rig.start(TEST2_BEARER, "nina", order_ticket(&home_key(), "nina", "fg-1")).await.expect_err("signed by the other site's key");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");

    // Signed by the right key, then given more than was signed.
    let mut t = signed_ticket(&test2_key(), "nina", "fg-2");
    t.caps.insert("pack_bytes".into(), 1u64 << 50);
    let e = rig.start(TEST2_BEARER, "nina", serde_json::to_vec(&t).unwrap()).await.expect_err("altered after signing");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");

    // A signature of zeros.
    let mut t = signed_ticket(&test2_key(), "nina", "fg-3");
    t.signature = base64_encode(&[0u8; 64]);
    let e = rig.start(TEST2_BEARER, "nina", serde_json::to_vec(&t).unwrap()).await.expect_err("an all-zero signature");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");

    assert_eq!(rig.rows().await, 0, "a forged ticket books nothing");
    assert!(rig.state.registry.spend_site_nonce(TEST2, "fg-2"), "a refused forgery did not spend the nonce");
}

/// RULE: a ticket is spent once. The same bytes again are a replay.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_replayed_ticket_is_refused() {
    let rig = Sites::up().await;
    let ticket = order_ticket(&test2_key(), "olle", "rp-once");
    rig.start(TEST2_BEARER, "olle", ticket.clone()).await.expect("the first spend");
    let e = rig.start(TEST2_BEARER, "olle", ticket).await.expect_err("the same ticket again");
    assert_eq!(e.code(), Code::PermissionDenied, "{e:?}");
    assert!(e.message().contains(SPENT), "{}", e.message());
}

fn ssh_key(seed: u8) -> sshsig::ssh_key::PrivateKey {
    sshsig::ssh_key::PrivateKey::from(sshsig::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 with(c: &control_auth::Credential) -> Request<ListProductsRequest> {
    let mut r = Request::new(ListProductsRequest {});
    r.metadata_mut().insert(METADATA_KEY, MetadataValue::try_from(c.to_header_value()).unwrap());
    r
}

/// RULE: the control API's per-call SSHSIG credential opens it only for a known principal,
/// and only once.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_control_credential_from_a_stranger_or_used_twice_is_refused() {
    let (console, stranger) = (ssh_key(7), ssh_key(8));
    let state = Arc::new(AppState::in_memory(Registry::new()));
    state.control.set(&BTreeMap::from([("monetize-ui".to_string(), console.public_key().to_openssh().unwrap())]));
    let server = TestServer::spawn(state.clone(), SERVICE).await;
    let mut client = ProductsClient::new(Channel::from_shared(server.endpoint()).unwrap().connect().await.unwrap());

    let forged = control_auth::sign(&stranger, now_ms(), "sec-nonce-000000001", "Products.List").unwrap();
    let e = client.list(with(&forged)).await.expect_err("a key monetize does not know");
    assert_eq!(e.code(), Code::Unauthenticated, "{e:?}");

    let once = control_auth::sign(&console, now_ms(), "sec-nonce-000000002", "Products.List").unwrap();
    client.list(with(&once)).await.expect("the principal's first use");
    let e = client.list(with(&once)).await.expect_err("the same credential again");
    assert_eq!(e.code(), Code::Unauthenticated, "{e:?}");
    assert!(e.message().contains("already been used"), "{}", e.message());
}

/// RULE: a callback body over [`MAX_CALLBACK_BYTES`] is refused 413 before the vendor's parser
/// runs, even when it carries the right signature.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_oversized_callback_is_refused_before_any_parser() {
    let pay = CallbackPay::new("mockpay-shared-secret");
    let state = Arc::new(AppState::in_memory(Registry::new().sells("gunnar", pay.clone())));
    let server = TestServer::spawn(state.clone(), SERVICE).await;
    let addr = server.addr;
    let post = |body: Vec<u8>| {
        let headers = pay.signed_headers();
        tokio::task::spawn_blocking(move || http_post(addr, "/callback/mockpay", &headers, &body).unwrap())
    };
    let (code, reply) = post(vec![b' '; MAX_CALLBACK_BYTES + 1]).await.unwrap();
    assert_eq!(code, 413, "{reply}");
    assert!(reply.contains("too large"), "{reply}");
    assert_eq!(state.refusals.stats().total, 0, "the vendor's parser was never run for it");
    // At the cap it is read and parsed (and refused by the parser as not a callback).
    let (code, _) = post(vec![b' '; MAX_CALLBACK_BYTES]).await.unwrap();
    assert_ne!(code, 413, "a body at the cap is not too large");
}