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/control_sshsig.rs

//! **The control API from outside the estate: SSHSIG per call** (owner
//! 2026-09-28). The shared operator console holds an SSH key; this monetize
//! knows its PUBLIC half as the admin principal `monetize-ui`
//! (`control_principals`). Every call is signed for its own method, once.
//!
//! Proven over a real gRPC listener: the principal's signed call is served
//! WITHOUT the bearer; the same credential replayed, or presented at another
//! method, is refused; a key monetize does not know is refused; a principal
//! added to the config later is taken in live; the bearer still opens the API
//! (the console inside the estate, on loopback).

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

use monetize_proto::control_auth::{self, sshsig, METADATA_KEY};
use monetize_proto::products_client::ProductsClient;
use monetize_proto::ListProductsRequest;
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::Request;

const BEARER: &str = "the-estates-own-bearer-0123456789";

fn key(seed: u8) -> sshsig::ssh_key::PrivateKey {
    sshsig::ssh_key::PrivateKey::from(sshsig::ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]))
}

fn line(k: &sshsig::ssh_key::PrivateKey) -> String {
    k.public_key().to_openssh().unwrap()
}

fn now_ms() -> i64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64
}

fn signed(k: &sshsig::ssh_key::PrivateKey, nonce: &str, method: &str) -> Request<ListProductsRequest> {
    let c = control_auth::sign(k, now_ms(), nonce, method).unwrap();
    let mut r = Request::new(ListProductsRequest {});
    r.metadata_mut().insert(METADATA_KEY, MetadataValue::try_from(c.to_header_value()).unwrap());
    r
}

#[tokio::test(flavor = "multi_thread")]
async fn a_control_principal_signs_each_call_and_nothing_else_opens_the_api_without_the_bearer() {
    let console = key(7);
    let stranger = key(8);
    let state = Arc::new(AppState::in_memory(Registry::new()));
    state.control.set(&BTreeMap::from([("monetize-ui".to_string(), line(&console))]));
    let server = TestServer::spawn(state.clone(), BEARER).await;
    let ch = Channel::from_shared(server.endpoint()).unwrap().connect().await.unwrap();
    let mut client = ProductsClient::new(ch);

    // The principal, signed for THIS method, with no bearer at all.
    client.list(signed(&console, "nonce-one-0000000001", "Products.List")).await.expect("the console's signed call is served");

    // Nobody else, and nothing twice.
    let c = control_auth::sign(&console, now_ms(), "nonce-two-0000000002", "Products.List").unwrap();
    let with = |c: &control_auth::Credential| {
        let mut r = Request::new(ListProductsRequest {});
        r.metadata_mut().insert(METADATA_KEY, MetadataValue::try_from(c.to_header_value()).unwrap());
        r
    };
    client.list(with(&c)).await.expect("first use");
    assert_eq!(client.list(with(&c)).await.unwrap_err().code(), tonic::Code::Unauthenticated, "a replayed credential");
    let other = client.list(signed(&console, "nonce-three-00000003", "Purchase.Start")).await.unwrap_err();
    assert_eq!(other.code(), tonic::Code::Unauthenticated, "signed for another method");
    assert!(other.message().contains("Purchase.Start"), "{}", other.message());
    assert_eq!(client.list(signed(&stranger, "nonce-four-000000004", "Products.List")).await.unwrap_err().code(), tonic::Code::Unauthenticated, "a key monetize does not know");
    assert_eq!(client.list(Request::new(ListProductsRequest {})).await.unwrap_err().code(), tonic::Code::Unauthenticated, "no credential, no bearer");

    // A principal fed later is taken in live; a removed one is out.
    state.control.set(&BTreeMap::from([("monetize-ui".to_string(), line(&console)), ("second-console".to_string(), line(&stranger))]));
    client.list(signed(&stranger, "nonce-five-000000005", "Products.List")).await.expect("added live");
    state.control.set(&BTreeMap::new());
    assert_eq!(client.list(signed(&console, "nonce-six-0000000006", "Products.List")).await.unwrap_err().code(), tonic::Code::Unauthenticated, "revoked by removing the line");

    // The bearer still opens it (the console inside the estate).
    let mut r = Request::new(ListProductsRequest {});
    r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {BEARER}")).unwrap());
    client.list(r).await.expect("the bearer is unchanged");
}

/// A gunnar credential (another product's scheme) never opens monetize.
#[tokio::test(flavor = "multi_thread")]
async fn another_products_credential_is_not_a_monetize_credential() {
    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
    struct Gunnarish;
    impl sshsig::Scheme for Gunnarish {
        const METADATA_KEY: &'static str = METADATA_KEY;
        const SCHEME: &'static str = "monetize-sshsig-v1";
        const SCHEME_UNBOUND: Option<&'static str> = None;
        const NAMESPACE: &'static str = "gunnar-grpc";
        const PREAMBLE: &'static str = "monetize-grpc-auth-v1";
        const PREAMBLE_UNBOUND: &'static str = "";
        type Error = sshsig::Refusal;
    }
    let console = key(9);
    let state = Arc::new(AppState::in_memory(Registry::new()));
    state.control.set(&BTreeMap::from([("monetize-ui".to_string(), line(&console))]));
    let server = TestServer::spawn(state, BEARER).await;
    let ch = Channel::from_shared(server.endpoint()).unwrap().connect().await.unwrap();
    let c = sshsig::sign::<Gunnarish>(&console, now_ms(), "nonce-gunnar-0000001", Some("Products.List")).unwrap();
    let mut r = Request::new(ListProductsRequest {});
    r.metadata_mut().insert(METADATA_KEY, MetadataValue::try_from(c.to_header_value()).unwrap());
    assert_eq!(ProductsClient::new(ch).list(r).await.unwrap_err().code(), tonic::Code::Unauthenticated, "signed under gunnar's namespace");
}