use std::sync::Arc;
use gunnar_grpc::auth::AuthGate;
use gunnar_grpc::Identity as _;
use gunnar_grpc::{
sign_credential_for, AccessService, AccountsService, AuthMode, Caller, ControlPlane, EventBus,
MemoryControlPlane, ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::*;
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};
const RP_ID: &str = "test2.example";
const ORIGIN: &str = "https://test2.example";
const DAY_MS: i64 = 86_400_000;
const BREAK_GLASS: &str = "break-glass-of-the-installer-0000000000000000000001";
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_millis() as i64
}
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")
}
fn b64url(bytes: &[u8]) -> String {
gunnar_grpc::webauthn::b64url(bytes)
}
fn hash_of(secret: &str) -> String {
gunnar_grpc::webauthn::sha256_hex(secret.as_bytes())
}
struct Yubikey {
key: p256::ecdsa::SigningKey,
id: Vec<u8>,
}
impl Yubikey {
fn new(seed: u8, id: &[u8]) -> Yubikey {
Yubikey {
key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar"),
id: id.to_vec(),
}
}
fn public(&self) -> Vec<u8> {
self.key.verifying_key().to_sec1_point(false).as_bytes().to_vec()
}
fn register(&self, challenge: &[u8], invite_secret: &str) -> WebRegisterRequest {
WebRegisterRequest {
invite_secret: invite_secret.into(),
principal_id: String::new(),
credential_id: self.id.clone(),
public_key: self.public(),
label: "YubiKey".into(),
client_data_json: format!(
r#"{{"type":"webauthn.create","challenge":"{}","origin":"{ORIGIN}"}}"#,
b64url(challenge)
)
.into_bytes(),
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
sign_count: 0,
attestation_object: gunnar_grpc::webauthn::attestation_none(RP_ID, &self.id, &self.public(), 0),
}
}
fn login(&self, challenge: &[u8], counter: u32, session: &ssh_key::PrivateKey) -> WebLoginRequest {
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(0x01 | 0x04);
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);
WebLoginRequest {
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.into(),
origin: ORIGIN.into(),
session_public_key: session.public_key().to_openssh().expect("openssh"),
session_expires_at_unix_ms: now_ms() + DAY_MS,
require_user_verification: false,
}
}
}
struct Estate {
plane: Arc<MemoryControlPlane>,
ctx: ServiceContext,
console: Caller,
}
impl Estate {
fn new(conjured: bool) -> Estate {
let plane = Arc::new(MemoryControlPlane::new());
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), ServerConfig::default());
assert_eq!(ctx.config.auth, AuthMode::Required);
let mut estate = Estate {
plane,
ctx,
console: Caller {
principal_id: String::new(),
fingerprint: String::new(),
machine: true,
authenticated: true,
},
};
let (console, _) = estate.enrolled("gunnar-ui", PrincipalKind::Machine);
estate.console = console;
estate.plane.grant_server("gunnar-ui", GrantLevel::Admin);
if conjured {
estate
.plane
.create_namespace("rickard", "")
.expect("the namespace a first push conjured");
}
estate
}
fn enrolled(&self, id: &str, kind: PrincipalKind) -> (Caller, ssh_key::PrivateKey) {
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"), key)
}
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-first-admin-{: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:?}"))
}
fn authed<T>(caller: &Caller, body: T) -> Request<T> {
let mut request = Request::new(body);
request.extensions_mut().insert(caller.clone());
request
}
fn access(&self) -> AccessService {
AccessService::new(self.ctx.clone())
}
fn accounts(&self) -> AccountsService {
AccountsService::new(self.ctx.clone())
}
async fn claim(&self, hash: &str) -> Result<ClaimOperatorResponse, tonic::Status> {
self.accounts()
.claim_operator(Estate::authed(
&self.console,
ClaimOperatorRequest {
name: "rickard".into(),
bootstrap_invite_sha256: hash.into(),
},
))
.await
.map(|r| r.into_inner())
}
async fn challenge(&self) -> Vec<u8> {
self.access()
.web_challenge(Estate::authed(&self.console, WebChallengeRequest::default()))
.await
.expect("the relay gets a challenge")
.into_inner()
.challenge
}
async fn register(&self, by: &Yubikey, secret: &str) -> Result<WebRegisterResponse, tonic::Status> {
let challenge = self.challenge().await;
self.access()
.web_register(Estate::authed(&self.console, by.register(&challenge, secret)))
.await
.map(|r| r.into_inner())
}
}
#[tokio::test]
async fn the_break_glass_token_is_the_operators_first_invite_and_his_passkey_logs_in_as_him() {
for conjured in [false, true] {
let estate = Estate::new(conjured);
let claimed = estate.claim(&hash_of(BREAK_GLASS)).await.expect("the installer's claim");
let operator = claimed.principal.clone().expect("the operator");
assert_eq!(operator.id, "rickard", "conjured={conjured}");
assert_eq!(claimed.adopted_namespace, conjured, "the conjured namespace is ADOPTED, never a refusal");
assert!(claimed.bootstrap_invite, "the token's hash is pending as his first invite");
let pending = estate
.access()
.list_invites(Estate::authed(&estate.console, ListInvitesRequest::default()))
.await
.expect("listed")
.into_inner()
.invites;
assert_eq!(pending.len(), 1, "{pending:?}");
assert_eq!(pending[0].invite_id, gunnar_grpc::seam::OPERATOR_INVITE_ID);
assert_eq!(pending[0].principal_id, "rickard");
assert_eq!(pending[0].server_level, GrantLevel::Unspecified as i32, "no admin by invite");
let yubikey = Yubikey::new(0x37, b"the-owners-yubikey");
let registered = estate.register(&yubikey, BREAK_GLASS).await.expect("the first admin passkey registers");
assert_eq!(registered.principal.as_ref().map(|p| p.id.as_str()), Some("rickard"));
assert_eq!(registered.server_level, GrantLevel::Unspecified as i32);
let session = mint();
let challenge = estate.challenge().await;
let login = estate
.access()
.web_login(Estate::authed(&estate.console, yubikey.login(&challenge, 1, &session)))
.await
.expect("his passkey logs in")
.into_inner();
assert_eq!(login.principal.as_ref().map(|p| p.id.as_str()), Some("rickard"));
let him = estate.gate(&session).expect("the session key authenticates");
assert_eq!(him.principal_id, "rickard");
assert!(
estate.plane.may("rickard", &gunnar_grpc::Access::Server, GrantLevel::Admin).expect("asked"),
"the operator is the estate's admin"
);
let laptop = mint().public_key().to_openssh().expect("openssh");
estate
.access()
.add_key(Estate::authed(
&him,
AddKeyRequest {
principal_id: "rickard".into(),
openssh_public_key: laptop.clone(),
comment: "laptop".into(),
attestation: None,
expires_at_unix_ms: None,
},
))
.await
.expect("his own SSH key, added as him");
let fingerprint = estate
.plane
.list_keys(Some("rickard"))
.expect("keys")
.into_iter()
.any(|k| k.comment == "laptop");
assert!(fingerprint, "gunnar holds his laptop key on rickard");
}
}
#[tokio::test]
async fn the_bootstrap_is_spent_by_the_first_passkey() {
let estate = Estate::new(true);
estate.claim(&hash_of(BREAK_GLASS)).await.expect("claimed");
estate
.register(&Yubikey::new(0x37, b"the-owners-yubikey"), BREAK_GLASS)
.await
.expect("the first passkey");
let copied = estate
.register(&Yubikey::new(0x38, b"somebody-with-the-token"), BREAK_GLASS)
.await
.expect_err("the token is spent");
assert_eq!(copied.code(), Code::PermissionDenied, "{copied:?}");
let again = estate.claim(&hash_of(BREAK_GLASS)).await.expect("a later feed's claim");
assert!(!again.bootstrap_invite, "no second bootstrap once he holds a passkey");
assert!(estate
.access()
.list_invites(Estate::authed(&estate.console, ListInvitesRequest::default()))
.await
.expect("listed")
.into_inner()
.invites
.is_empty());
}
#[tokio::test]
async fn a_token_in_place_of_its_hash_is_refused_and_nothing_is_claimed() {
let estate = Estate::new(false);
let refused = estate.claim(BREAK_GLASS).await.expect_err("never the token");
assert_eq!(refused.code(), Code::InvalidArgument, "{refused:?}");
assert!(refused.message().contains("never the token itself"), "{}", refused.message());
assert!(estate.plane.principal("rickard").expect("asked").is_none(), "nothing was claimed");
}