crates/gunnar-grpc/tests/machine_enrolment_common/mod.rs
//! The harness both machine-enrolment test binaries share: a memory plane,
//! the console (the relay), kim (a person, server admin, with a passkey), lee
//! (a person, no admin, with a passkey) and P-256 authenticators that sign
//! what a browser or the kit signs. A login is the identity core's
//! (IDENTITY-HOST): the kit logs in through it, and an approval reads the
//! approver's core session ([`gunnar_grpc::auth::PersonSession`]).
#![allow(dead_code)]
use std::sync::Arc;
use gunnar_grpc::auth::{AuthGate, PersonSession, SESSION_METADATA_KEY};
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables,
};
use gunnar_grpc::{
sign_credential_for, AccessService, Caller, ControlPlane, EventBus, MemoryControlPlane,
ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::*;
use nornir_identity::{Assertion, Ceremony, Identity, LoggedIn, NoMail, Refused, Relay, Site};
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::Request;
pub const RP_ID: &str = "console.example";
pub const ORIGIN: &str = "https://console.example";
pub const UP: u8 = 0x01;
pub const UV: u8 = 0x04;
pub const TOKEN: &str = "the-installers-break-glass-token";
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_millis() as i64
}
pub fn mint() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::random(
&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
ssh_key::Algorithm::Ed25519,
)
.expect("an ed25519 key")
}
pub fn b64url(bytes: &[u8]) -> String {
gunnar_grpc::webauthn::b64url(bytes)
}
/// One authenticator: a P-256 key and its credential id.
pub struct Authenticator {
pub key: p256::ecdsa::SigningKey,
pub id: Vec<u8>,
}
impl Authenticator {
pub fn new(seed: u8, id: &[u8]) -> Authenticator {
Authenticator {
key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar"),
id: id.to_vec(),
}
}
pub fn public(&self) -> Vec<u8> {
self.key
.verifying_key()
.to_sec1_point(false)
.as_bytes()
.to_vec()
}
/// A `navigator.credentials.get` over `challenge`.
pub fn assert(
&self,
ceremony_id: &str,
challenge: &[u8],
flags: u8,
counter: u32,
) -> Assertion {
let client_data = format!(
r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
b64url(challenge)
)
.into_bytes();
let mut auth_data = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
auth_data.push(flags);
auth_data.extend_from_slice(&counter.to_be_bytes());
let mut message = auth_data.clone();
message.extend_from_slice(&sha2::Sha256::digest(&client_data));
let signature: p256::ecdsa::Signature = self.key.sign(&message);
Assertion {
ceremony_id: ceremony_id.to_owned(),
credential_id: self.id.clone(),
client_data_json: client_data,
authenticator_data: auth_data,
signature: signature.to_der().as_bytes().to_vec(),
rp_id: RP_ID.to_owned(),
origin: ORIGIN.to_owned(),
}
}
/// A login with this key through the identity core: the session, or the
/// core's refusal.
pub fn login(&self, identity: &GunnarIdentity, counter: u32) -> Result<LoggedIn, Refused> {
let c: Ceremony = identity.login_start(&relay())?;
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
identity.login_finish(self.assert(&c.id, &challenge, UP | UV, counter))
}
}
/// The relay's relying party, as the core is configured with it.
pub fn relay() -> Relay {
Relay {
rp_id: RP_ID.to_owned(),
origin: ORIGIN.to_owned(),
client: None,
}
}
/// **A fresh step-up of the approver's own session** (the core's): the
/// approval carries the session the gate found, stepped up.
pub struct Fresh;
/// `clientDataJSON` for a creation ceremony.
pub fn created(challenge: &[u8], kind: &str, origin: &str) -> Vec<u8> {
format!(
r#"{{"type":"{kind}","challenge":"{}","origin":"{origin}","crossOrigin":false}}"#,
b64url(challenge)
)
.into_bytes()
}
pub struct Harness {
pub plane: Arc<MemoryControlPlane>,
pub identity: Arc<GunnarIdentity>,
pub ctx: ServiceContext,
/// The console: server Admin, the relay.
pub console: Caller,
/// A person who is a server admin, with a passkey.
pub kim: Caller,
pub kim_key: Authenticator,
/// A person who is NOT a server admin, with a passkey of her own.
pub lee: Caller,
pub lee_key: Authenticator,
}
impl Harness {
pub fn new() -> Harness {
Harness::with_ttl(gunnar_grpc::machine_enrolment::REQUEST_TTL_MS)
}
pub fn with_ttl(ttl_ms: i64) -> Harness {
let plane = Arc::new(MemoryControlPlane::new());
let control: Arc<dyn ControlPlane> = plane.clone();
let store = GunnarStore::new(
Arc::clone(&control),
IdentityTables::default(),
None,
system_clock(),
);
let core = nornir_identity::Config {
parties: vec![format!("{RP_ID}={ORIGIN}")
.parse()
.expect("the relying party")],
..nornir_identity::Config::default()
};
let identity = Arc::new(Identity::new(
GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
core,
));
let server = ServerConfig {
web_relying_parties: identity.config().parties.clone(),
..ServerConfig::default()
};
let mut ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), server)
.with_identity(Arc::clone(&identity));
ctx.machine_enrolments =
Arc::new(gunnar_grpc::machine_enrolment::Requests::with_ttl(ttl_ms));
let nobody = Caller {
principal_id: String::new(),
fingerprint: String::new(),
machine: true,
authenticated: true,
};
let mut h = Harness {
plane,
identity,
ctx,
console: nobody.clone(),
kim: nobody.clone(),
kim_key: Authenticator::new(1, b"kim-yubikey"),
lee: nobody,
lee_key: Authenticator::new(2, b"lee-yubikey"),
};
h.console = h.enrolled("gunnar-ui", PrincipalKind::Machine);
h.plane.grant_server("gunnar-ui", GrantLevel::Admin);
h.kim = h.enrolled("kim", PrincipalKind::User);
h.plane.grant_server("kim", GrantLevel::Admin);
h.plane
.add_credential(
"kim",
&h.kim_key.id,
&h.kim_key.public(),
"kim's YubiKey",
0,
)
.expect("kim's passkey");
h.lee = h.enrolled("lee", PrincipalKind::User);
h.plane
.add_credential(
"lee",
&h.lee_key.id,
&h.lee_key.public(),
"lee's YubiKey",
0,
)
.expect("lee's passkey");
h
}
/// A principal with a key, signed in through the real gate.
pub fn enrolled(&self, id: &str, kind: PrincipalKind) -> Caller {
let key = mint();
self.plane.add_principal(id, id, kind).expect("principal");
self.plane
.add_key(
id,
&key.public_key().to_openssh().expect("openssh"),
id,
None,
"",
None,
)
.expect("key");
self.gate(&key).expect("a registered key verifies")
}
pub fn gate(&self, key: &ssh_key::PrivateKey) -> Result<Caller, String> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let nonce = format!(
"nonce-machine-enrol-{:08}",
SEQ.fetch_add(1, Ordering::Relaxed)
);
let credential = sign_credential_for(key, now_ms(), &nonce, "Admin.Health").expect("sign");
let mut request = Request::new(());
request.metadata_mut().insert(
CREDENTIAL_METADATA_KEY,
credential.to_header_value().parse().expect("ascii"),
);
AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
.authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
.map_err(|e| format!("{e:?}"))
}
/// The caller the real gate makes of a core session, or why it refuses.
pub fn gate_session(&self, token: &str) -> Result<Caller, String> {
let mut request = Request::new(());
request
.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
.with_identity(Some(Arc::clone(&self.identity)))
.authenticate_request(&request, now_ms())
.map_err(|e| format!("{e:?}"))
}
pub fn authed<T>(caller: &Caller, body: T) -> Request<T> {
let mut request = Request::new(body);
request.extensions_mut().insert(caller.clone());
request
}
pub fn access(&self) -> AccessService {
AccessService::new(self.ctx.clone())
}
/// A machine's creation challenge, as the console relays it.
pub async fn challenge(&self) -> Vec<u8> {
self.access()
.machine_challenge(Harness::authed(
&self.console,
MachineChallengeRequest::default(),
))
.await
.expect("the relay gets a machine's challenge")
.into_inner()
.challenge
}
/// The kit's request, as the console relays it.
pub async fn ask_as(
&self,
relay: &Caller,
kit: &Authenticator,
machine: &str,
level: GrantLevel,
) -> Result<MachineEnrolment, tonic::Status> {
self.ask_raw(relay, kit, machine, level as i32).await
}
/// The kit's request with a raw `server_level` (one gunnar may not know).
pub async fn ask_level(
&self,
kit: &Authenticator,
machine: &str,
level: i32,
) -> Result<MachineEnrolment, tonic::Status> {
self.ask_raw(&self.console, kit, machine, level).await
}
async fn ask_raw(
&self,
relay: &Caller,
kit: &Authenticator,
machine: &str,
level: i32,
) -> Result<MachineEnrolment, tonic::Status> {
let challenge = self.challenge().await;
self.access()
.request_machine_enrolment(Harness::authed(
relay,
RequestMachineEnrolmentRequest {
machine: machine.to_owned(),
label: format!("installer kit on {}", machine.trim_start_matches("kit-")),
server_level: level,
credential_id: kit.id.clone(),
public_key: kit.public(),
client_data_json: created(&challenge, "webauthn.create", ORIGIN),
rp_id: RP_ID.to_owned(),
origin: ORIGIN.to_owned(),
sign_count: 0,
attestation_object: gunnar_grpc::webauthn::attestation_none(
RP_ID,
&kit.id,
&kit.public(),
0,
),
},
))
.await
.map(|r| r.into_inner().request.expect("the request"))
}
pub async fn ask(
&self,
kit: &Authenticator,
machine: &str,
level: GrantLevel,
) -> MachineEnrolment {
self.ask_as(&self.console, kit, machine, level)
.await
.expect("the request is held")
}
pub async fn status(&self, id: &str) -> MachineEnrolment {
self.access()
.machine_enrolment_status(Harness::authed(
&self.console,
MachineEnrolmentStatusRequest {
request_id: id.to_owned(),
},
))
.await
.expect("the relay reads a request's state")
.into_inner()
.request
.expect("the request")
}
pub async fn approve(
&self,
caller: &Caller,
request: &MachineEnrolment,
step_up: Option<Fresh>,
) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
self.approve_at(caller, request, step_up, GrantLevel::Unspecified)
.await
}
/// The approval, the approver GIVING `level` (UNSPECIFIED: the asked one).
pub async fn approve_at(
&self,
caller: &Caller,
request: &MachineEnrolment,
step_up: Option<Fresh>,
level: GrantLevel,
) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
let mut call = Harness::authed(
caller,
ApproveMachineEnrolmentRequest {
request_id: request.request_id.clone(),
code: request.code.clone(),
server_level: level as i32,
},
);
if step_up.is_some() {
call.extensions_mut().insert(PersonSession {
stepped_up: true,
stepped_up_at_unix_ms: None,
});
}
self.access()
.approve_machine_enrolment(call)
.await
.map(|r| r.into_inner())
}
/// The approval, from a real core session through the real gate: the
/// step-up is whatever the core says of `token`.
pub async fn approve_with_session(
&self,
token: &str,
request: &MachineEnrolment,
) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
let caller = self
.gate_session(token)
.map_err(tonic::Status::unauthenticated)?;
let stepped_up = self
.identity
.session(token)
.map(|v| v.stepped_up)
.unwrap_or(false);
let mut call = Harness::authed(
&caller,
ApproveMachineEnrolmentRequest {
request_id: request.request_id.clone(),
code: request.code.clone(),
server_level: GrantLevel::Unspecified as i32,
},
);
call.extensions_mut().insert(PersonSession {
stepped_up,
stepped_up_at_unix_ms: None,
});
self.access()
.approve_machine_enrolment(call)
.await
.map(|r| r.into_inner())
}
pub async fn kims_step_up(&self) -> Fresh {
Fresh
}
/// Nothing landed for `machine`: no principal, no credential.
pub fn nothing_for(&self, machine: &str, kit: &Authenticator) {
assert!(
self.plane.principal(machine).expect("read").is_none(),
"{machine} was made"
);
assert!(
self.plane.credential(&kit.id).expect("read").is_none(),
"the kit's credential landed"
);
}
}
pub fn state(r: &MachineEnrolment) -> MachineEnrolmentState {
MachineEnrolmentState::try_from(r.state).expect("a state")
}