use std::sync::Arc;
use gunnar_grpc::auth::{AuthGate, SESSION_METADATA_KEY};
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables,
};
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 nornir_identity::conformance::{config, relay, ORIGIN};
use nornir_identity::test_host::SoftPasskey;
use nornir_identity::{Identity, NoMail, Site};
use sha2::Digest as _;
use tonic::{Code, Request};
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 hash_of(secret: &str) -> String {
sha2::Sha256::digest(secret.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
struct Estate {
plane: Arc<MemoryControlPlane>,
ctx: ServiceContext,
identity: Arc<GunnarIdentity>,
console: Caller,
}
impl Estate {
fn new(conjured: bool) -> Estate {
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 identity = Arc::new(Identity::new(
GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
config(),
));
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_identity(Arc::clone(&identity));
assert_eq!(ctx.config.auth, AuthMode::Required);
let key = mint();
plane
.add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
.expect("the console");
plane
.add_key(
"gunnar-ui",
&key.public_key().to_openssh().expect("openssh"),
"gunnar-ui",
None,
"",
None,
)
.expect("key");
plane.grant_server("gunnar-ui", GrantLevel::Admin);
if conjured {
plane
.create_namespace("rickard", "")
.expect("the namespace a first push conjured");
}
let mut estate = Estate {
plane,
ctx,
identity,
console: Caller {
principal_id: String::new(),
fingerprint: String::new(),
machine: true,
authenticated: true,
},
};
estate.console = estate.gate_key(&key).expect("the console's key verifies");
estate
}
fn gate(&self) -> AuthGate {
AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
.with_identity(Some(Arc::clone(&self.identity)))
}
fn gate_key(&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"),
);
self.gate()
.authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
.map_err(|e| format!("{e:?}"))
}
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"));
self.gate()
.authenticate_request(&request, now_ms())
.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
}
async fn claim(&self, hash: &str) -> Result<ClaimOperatorResponse, tonic::Status> {
AccountsService::new(self.ctx.clone())
.claim_operator(Estate::authed(
&self.console,
ClaimOperatorRequest {
name: "rickard".into(),
bootstrap_invite_sha256: hash.into(),
},
))
.await
.map(|r| r.into_inner())
}
fn register(
&self,
key: &mut SoftPasskey,
secret: &str,
) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
let c = self.identity.redeem_start(secret, "", &relay())?;
self.identity.redeem_finish(key.register(&c, ORIGIN))
}
}
#[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 mut yubikey = SoftPasskey::new(0x37);
let him = estate
.register(&mut yubikey, BREAK_GLASS)
.expect("the first admin passkey registers");
assert_eq!(him.session.user_id, "rickard");
assert!(him.session.admin, "the operator is the estate's admin");
assert_eq!(
estate
.plane
.credentials(Some("rickard"))
.expect("listed")
.len(),
1,
"the passkey is Gunnar's, on him"
);
let caller = estate
.gate_session(&him.token)
.expect("the session authenticates");
assert_eq!(caller.principal_id, "rickard");
let laptop = mint().public_key().to_openssh().expect("openssh");
AccessService::new(estate.ctx.clone())
.add_key(Estate::authed(
&caller,
AddKeyRequest {
principal_id: "rickard".into(),
openssh_public_key: laptop,
comment: "laptop".into(),
attestation: None,
expires_at_unix_ms: None,
},
))
.await
.expect("his own SSH key, added as him");
assert!(
estate
.plane
.list_keys(Some("rickard"))
.expect("keys")
.into_iter()
.any(|k| k.comment == "laptop"),
"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(&mut SoftPasskey::new(0x37), BREAK_GLASS)
.expect("the first passkey");
let copied = estate
.register(&mut SoftPasskey::new(0x38), BREAK_GLASS)
.expect_err("the token is spent");
assert_eq!(
copied.code,
nornir_identity::Code::InviteNotOpen,
"{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.plane.first_invite().expect("read").is_none(),
"nothing pending at the plane"
);
}
#[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"
);
}
#[tokio::test]
async fn bootstrap_status_says_whether_the_break_glass_token_is_spent() {
use gunnar_proto::bootstrap_server::Bootstrap as _;
let estate = Estate::new(false);
let ask = || async {
gunnar_grpc::BootstrapService::new(estate.ctx.clone())
.status(Request::new(BootstrapStatusRequest {}))
.await
.expect("Status answers")
.into_inner()
.break_glass_open
};
assert_eq!(
ask().await,
Some(true),
"no admin holds a passkey: the token is the way in"
);
estate
.claim(&hash_of(BREAK_GLASS))
.await
.expect("the installer's claim");
assert_eq!(ask().await, Some(true), "the first invite is pending");
estate
.register(&mut SoftPasskey::new(0x37), BREAK_GLASS)
.expect("the first admin passkey");
assert_eq!(
ask().await,
Some(false),
"the operator holds a passkey: the bootstrap is spent"
);
}