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