use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use gunnar_proto::{GrantLevel, RepoPath};
use tonic::{Request, Status};
use crate::seam::ControlPlane;
pub const KEY_ENROLLING: &str = "[key-enrolling]";
pub const CREDENTIAL_METADATA_KEY: &str = "gunnar-authorization";
pub const CREDENTIAL_SCHEME: &str = "gunnar-sshsig-v2";
pub const CREDENTIAL_SCHEME_V1: &str = "gunnar-sshsig-v1";
pub const SSHSIG_NAMESPACE: &str = "gunnar-grpc";
pub const CHALLENGE_PREAMBLE: &str = "gunnar-grpc-auth-v1";
pub const CHALLENGE_PREAMBLE_V2: &str = "gunnar-grpc-auth-v2";
pub const MAX_CLOCK_SKEW_MS: i64 = nornir_auth_sshsig::MAX_CLOCK_SKEW_MS;
pub const CLOCK_SKEW_PHRASE: &str = "away from this server's clock";
pub const MIN_NONCE_LEN: usize = nornir_auth_sshsig::MIN_NONCE_LEN;
pub const AUDIT_TARGET: &str = "gunnar::audit";
pub const ANONYMOUS_PRINCIPAL_ID: &str = "anonymous";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AuthMode {
#[default]
Required,
AllowAllInsecure,
}
impl AuthMode {
pub fn is_required(&self) -> bool {
matches!(self, AuthMode::Required)
}
pub fn as_str(&self) -> &'static str {
match self {
AuthMode::Required => "required",
AuthMode::AllowAllInsecure => "allow-all-insecure",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Access {
Server,
Namespace(String),
Repo(RepoPath),
}
impl Access {
pub fn repo(path: &RepoPath) -> Access {
Access::Repo(path.clone())
}
pub fn namespace(name: &str) -> Access {
Access::Namespace(name.to_owned())
}
}
impl std::fmt::Display for Access {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Access::Server => f.write_str("server"),
Access::Namespace(name) => write!(f, "namespace:{name}"),
Access::Repo(path) => write!(f, "repo:{}/{}", path.namespace, path.repo),
}
}
}
pub fn level_name(level: GrantLevel) -> &'static str {
match level {
GrantLevel::Unspecified => "unspecified",
GrantLevel::Read => "read",
GrantLevel::Write => "write",
GrantLevel::Admin => "admin",
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Caller {
pub principal_id: String,
pub fingerprint: String,
pub machine: bool,
pub authenticated: bool,
}
impl Caller {
pub const INSECURE_PRINCIPAL: &'static str = "(unauthenticated: allow-all-insecure)";
fn insecure() -> Caller {
Caller {
principal_id: Caller::INSECURE_PRINCIPAL.to_owned(),
fingerprint: String::new(),
machine: false,
authenticated: false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct GunnarScheme;
impl nornir_auth_sshsig::Scheme for GunnarScheme {
const METADATA_KEY: &'static str = CREDENTIAL_METADATA_KEY;
const SCHEME: &'static str = CREDENTIAL_SCHEME;
const SCHEME_UNBOUND: Option<&'static str> = Some(CREDENTIAL_SCHEME_V1);
const NAMESPACE: &'static str = SSHSIG_NAMESPACE;
const PREAMBLE: &'static str = CHALLENGE_PREAMBLE_V2;
const PREAMBLE_UNBOUND: &'static str = CHALLENGE_PREAMBLE;
type Error = AuthFailure;
}
impl From<nornir_auth_sshsig::CredentialError> for AuthFailure {
fn from(e: nornir_auth_sshsig::CredentialError) -> AuthFailure {
match e {
nornir_auth_sshsig::CredentialError::Missing => AuthFailure::Missing,
nornir_auth_sshsig::CredentialError::Malformed(why) => AuthFailure::Malformed(why),
}
}
}
pub type Credential = nornir_auth_sshsig::Credential<GunnarScheme>;
pub fn challenge(fingerprint: &str, issued_at_unix_ms: i64, nonce: &str) -> String {
nornir_auth_sshsig::challenge::<GunnarScheme>(fingerprint, issued_at_unix_ms, nonce)
}
pub fn challenge_bound(fingerprint: &str, issued_at_unix_ms: i64, nonce: &str, method: &str) -> String {
nornir_auth_sshsig::challenge_bound::<GunnarScheme>(fingerprint, issued_at_unix_ms, nonce, method)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CalledMethod(pub String);
pub use nornir_auth_sshsig::{armor_to_line, line_to_armor, method_name_from_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFailure {
Missing,
Malformed(String),
Stale { skew_ms: i64 },
Replayed,
MethodMismatch { signed: String, called: String },
Unbound,
ReplayGuardFull,
NotVerified,
Unavailable(String),
Enrolling,
Anonymous,
CertificateNotRegistered,
}
impl AuthFailure {
pub fn reason(&self) -> &'static str {
match self {
AuthFailure::Missing => "missing",
AuthFailure::Malformed(_) => "malformed",
AuthFailure::Stale { .. } => "stale",
AuthFailure::Replayed => "replayed",
AuthFailure::MethodMismatch { .. } => "method-mismatch",
AuthFailure::Unbound => "unbound",
AuthFailure::ReplayGuardFull => "replay-guard-full",
AuthFailure::NotVerified => "not-verified",
AuthFailure::Unavailable(_) => "unavailable",
AuthFailure::Enrolling => "enrolling",
AuthFailure::Anonymous => "anonymous-is-not-a-caller",
AuthFailure::CertificateNotRegistered => "certificate-not-registered",
}
}
pub fn status(&self) -> Status {
match self {
AuthFailure::Missing => Status::unauthenticated(format!(
"this control plane requires a credential. Send `{CREDENTIAL_METADATA_KEY}: \
{CREDENTIAL_SCHEME} <fingerprint> <issued_at_ms> <nonce> <sshsig>`, signed \
with a key registered in gunnar's key table (the ACL model)."
)),
AuthFailure::Malformed(why) => {
Status::unauthenticated(format!("malformed credential: {why}"))
}
AuthFailure::Stale { skew_ms } => Status::unauthenticated(format!(
"the credential is {skew_ms} ms {CLOCK_SKEW_PHRASE}; the window is \
±{MAX_CLOCK_SKEW_MS} ms"
)),
AuthFailure::Replayed => Status::unauthenticated(
"this credential has already been used; each one is single-use",
),
AuthFailure::MethodMismatch { signed, called } => Status::unauthenticated(format!(
"this credential was signed for {signed} and was presented at {called}; a \
credential is valid for one method only"
)),
AuthFailure::ReplayGuardFull => Status::resource_exhausted(
"this control plane is already remembering as many single-use credentials as it \
will (the replay guard is full of credentials that have not yet expired). It \
refuses rather than forgetting one, because a forgotten credential can be \
replayed. Retry; if this persists, something is minting credentials far faster \
than a fleet does.",
),
AuthFailure::Unbound => Status::unauthenticated(format!(
"this control plane requires a method-bound credential. Send \
`{CREDENTIAL_METADATA_KEY}: {CREDENTIAL_SCHEME} <fingerprint> <issued_at_ms> \
<nonce> <method> <sshsig>` rather than the method-blind \
{CREDENTIAL_SCHEME_V1}, which binds no method and is refused at every one."
)),
AuthFailure::NotVerified => {
Status::unauthenticated("the credential did not verify against any registered key")
}
AuthFailure::Unavailable(why) => {
Status::unavailable(format!("the identity table could not be read: {why}"))
}
AuthFailure::Enrolling => Status::unavailable(format!(
"{KEY_ENROLLING} this key was fed to this box by its installer and is being \
enrolled; ask again in a moment"
)),
AuthFailure::Anonymous => {
Status::unauthenticated("the credential did not verify against any registered key")
}
AuthFailure::CertificateNotRegistered => Status::unauthenticated(
"this client certificate is signed by the server's issuer but is not registered \
to any principal; register it with `gunnar key add`",
),
}
}
}
pub fn sign_credential(
key: &ssh_key::PrivateKey,
issued_at_unix_ms: i64,
nonce: &str,
) -> Result<Credential, SignError> {
nornir_auth_sshsig::sign::<GunnarScheme>(&sshsig_key(key)?, issued_at_unix_ms, nonce, None)
}
pub fn sign_credential_for(key: &ssh_key::PrivateKey, issued_at_unix_ms: i64, nonce: &str, method: &str) -> Result<Credential, SignError> {
nornir_auth_sshsig::sign::<GunnarScheme>(&sshsig_key(key)?, issued_at_unix_ms, nonce, Some(method))
}
pub type SignError = nornir_auth_sshsig::ssh_key::Error;
fn sshsig_key(key: &ssh_key::PrivateKey) -> Result<nornir_auth_sshsig::ssh_key::PrivateKey, SignError> {
let pem = key.to_openssh(ssh_key::LineEnding::LF).map_err(|_| SignError::FormatEncoding)?;
nornir_auth_sshsig::ssh_key::PrivateKey::from_openssh(pem.as_bytes())
}
pub fn verify_signature(openssh_public_key: &str, credential: &Credential) -> bool {
nornir_auth_sshsig::verify_signature(openssh_public_key, credential) && touched(credential)
}
fn touched(credential: &Credential) -> bool {
let armor = nornir_auth_sshsig::line_to_armor(&credential.signature_line);
nornir_auth_sshsig::ssh_key::SshSig::from_pem(armor.as_bytes())
.is_ok_and(|sig| crate::security_key::user_present(sig.signature()))
}
pub const RESTORE_METHOD: &str = "Bootstrap.Restore";
pub const RESTORE_UNAUTHENTICATED: &str = "restore-unauthenticated";
pub const RESTORE_NO_KEY: &str = "restore-no-key";
pub const RESTORE_OWNER_INSECURE: &str = "insecure-allow-all";
pub fn authenticate_restore(
mode: AuthMode,
keys: &[String],
replay: &ReplayGuard,
metadata: &tonic::metadata::MetadataMap,
now_unix_ms: i64,
) -> Result<String, Status> {
if mode == AuthMode::AllowAllInsecure {
return Ok(RESTORE_OWNER_INSECURE.to_owned());
}
if keys.is_empty() {
return Err(Status::permission_denied(format!(
"REFUSED [{RESTORE_NO_KEY}] {RESTORE_METHOD}: this box was never fed a key it may take a restore from, so it \
takes none — a fresh box has no identity table, and a restore stream decides its KEK, its admin, its host \
key and its issuer. Feed one: the installer's `admin_pubkey` / an admin `principal.<id>` (monetize's \
identity is one), or start gunnar-server with `--restore-pubkey <openssh line>` (GUNNAR_RESTORE_PUBKEY). \
Nothing was written; the box is still fresh"
)));
}
authenticate_fed(RESTORE_METHOD, RESTORE_UNAUTHENTICATED, keys, replay, metadata, now_unix_ms)
}
pub const BOOTSTRAP_BECOME_TWIN_METHOD: &str = "Bootstrap.BecomeTwin";
pub const BOOTSTRAP_INSTALL_TWIN_CERTIFICATE_METHOD: &str = "Bootstrap.InstallTwinCertificate";
pub const BOOTSTRAP_UNAUTHENTICATED: &str = "bootstrap-unauthenticated";
pub const BOOTSTRAP_NO_KEY: &str = "bootstrap-no-key";
pub fn authenticate_bootstrap(
mode: AuthMode,
method: &str,
keys: &[String],
replay: &ReplayGuard,
metadata: &tonic::metadata::MetadataMap,
now_unix_ms: i64,
) -> Result<String, Status> {
if mode == AuthMode::AllowAllInsecure {
return Ok(RESTORE_OWNER_INSECURE.to_owned());
}
if keys.is_empty() {
return Err(Status::permission_denied(format!(
"REFUSED [{BOOTSTRAP_NO_KEY}] {method}: this box was never fed a key it may be made a twin by, so it takes \
this from nobody — a fresh box has no identity table, and the twin it becomes follows whatever primary \
the caller names, KEK and admins included. Feed one: the installer's `admin_pubkey` / an admin \
`principal.<id>`, or start gunnar-server with `--restore-pubkey <openssh line>` (GUNNAR_RESTORE_PUBKEY). \
Nothing was written; the box is as it was"
)));
}
authenticate_fed(method, BOOTSTRAP_UNAUTHENTICATED, keys, replay, metadata, now_unix_ms)
}
fn authenticate_fed(
method: &str,
clause: &str,
keys: &[String],
replay: &ReplayGuard,
metadata: &tonic::metadata::MetadataMap,
now_unix_ms: i64,
) -> Result<String, Status> {
let unauthenticated = |why: String| {
Status::unauthenticated(format!(
"REFUSED [{clause}] {method} needs `{CREDENTIAL_METADATA_KEY}: {CREDENTIAL_SCHEME} …` \
bound to {method} and signed by a key this box was fed: {why}. Nothing was \
written; the box is as it was"
))
};
let credential = Credential::from_metadata(metadata).map_err(|failure| match failure {
AuthFailure::Missing => unauthenticated("the call carries no credential".to_owned()),
AuthFailure::Malformed(why) => unauthenticated(format!("the credential is malformed ({why})")),
other => unauthenticated(other.reason().to_owned()),
})?;
let skew = now_unix_ms - credential.issued_at_unix_ms;
if skew.abs() > MAX_CLOCK_SKEW_MS {
estate_clock::trigger::skew_refused(skew);
return Err(unauthenticated(format!("the credential is {skew} ms {CLOCK_SKEW_PHRASE}")));
}
match credential.method.as_deref() {
Some(m) if m == method => {}
Some(other) => return Err(unauthenticated(format!("it was signed for {other}"))),
None => return Err(unauthenticated(format!("it is a method-blind {CREDENTIAL_SCHEME_V1} credential"))),
}
if !keys.iter().any(|line| verify_signature(line, &credential)) {
return Err(unauthenticated(format!(
"{} is not one of the {} key(s) this box was fed, or its signature does not verify",
credential.fingerprint,
keys.len()
)));
}
match replay.accept(&credential, now_unix_ms) {
Accepted::Fresh => Ok(credential.fingerprint),
Accepted::Replayed => Err(unauthenticated("this credential has already been spent".to_owned())),
Accepted::Full => Err(AuthFailure::ReplayGuardFull.status()),
}
}
pub use nornir_auth_sshsig::{Accepted, ReplayGuard, DEFAULT_REPLAY_CAPACITY};
#[derive(Debug)]
pub struct UnboundCredentialLog {
inner: Mutex<UnboundInner>,
capacity: usize,
}
#[derive(Debug, Default)]
struct UnboundInner {
accepted: u64,
refused: u64,
last_seen_unix_ms: i64,
distinct_dropped: u64,
seen: std::collections::BTreeMap<String, UnboundSighting>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnboundSighting {
pub fingerprint: String,
pub principal_id: String,
pub method: String,
pub first_seen_unix_ms: i64,
pub last_seen_unix_ms: i64,
pub count: u64,
pub refused: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UnboundCredentialReport {
pub accepted: u64,
pub refused: u64,
pub last_seen_unix_ms: i64,
pub distinct_dropped: u64,
pub callers: Vec<UnboundSighting>,
}
impl UnboundCredentialReport {
pub fn nothing_unbound_has_been_served(&self) -> bool {
self.accepted == 0
}
}
pub const DEFAULT_UNBOUND_LOG_CAPACITY: usize = 64;
pub const UNKNOWN_METHOD: &str = "(no method in scope)";
impl Default for UnboundCredentialLog {
fn default() -> Self {
UnboundCredentialLog::new(DEFAULT_UNBOUND_LOG_CAPACITY)
}
}
impl UnboundCredentialLog {
pub fn new(capacity: usize) -> UnboundCredentialLog {
UnboundCredentialLog {
inner: Mutex::new(UnboundInner::default()),
capacity: capacity.max(1),
}
}
pub fn record(
&self,
fingerprint: &str,
principal_id: &str,
method: &str,
refused: bool,
now_unix_ms: i64,
) -> bool {
let mut inner = match self.inner.lock() {
Ok(inner) => inner,
Err(poisoned) => poisoned.into_inner(),
};
if refused {
inner.refused += 1;
} else {
inner.accepted += 1;
}
inner.last_seen_unix_ms = inner.last_seen_unix_ms.max(now_unix_ms);
let key = format!("{fingerprint}\u{0}{method}");
if let Some(row) = inner.seen.get_mut(&key) {
row.count += 1;
row.last_seen_unix_ms = row.last_seen_unix_ms.max(now_unix_ms);
row.refused = row.refused && refused;
if row.principal_id.is_empty() {
row.principal_id = principal_id.to_owned();
}
return false;
}
if inner.seen.len() >= self.capacity {
inner.distinct_dropped += 1;
return true;
}
inner.seen.insert(
key,
UnboundSighting {
fingerprint: fingerprint.to_owned(),
principal_id: principal_id.to_owned(),
method: method.to_owned(),
first_seen_unix_ms: now_unix_ms,
last_seen_unix_ms: now_unix_ms,
count: 1,
refused,
},
);
true
}
pub fn report(&self) -> UnboundCredentialReport {
let inner = match self.inner.lock() {
Ok(inner) => inner,
Err(poisoned) => poisoned.into_inner(),
};
let mut callers: Vec<UnboundSighting> = inner.seen.values().cloned().collect();
callers.sort_by(|a, b| {
a.first_seen_unix_ms
.cmp(&b.first_seen_unix_ms)
.then_with(|| a.fingerprint.cmp(&b.fingerprint))
.then_with(|| a.method.cmp(&b.method))
});
UnboundCredentialReport {
accepted: inner.accepted,
refused: inner.refused,
last_seen_unix_ms: inner.last_seen_unix_ms,
distinct_dropped: inner.distinct_dropped,
callers,
}
}
}
#[derive(Clone)]
pub struct AuthGate {
control: Arc<dyn ControlPlane>,
mode: AuthMode,
replay: Arc<ReplayGuard>,
unbound: Arc<UnboundCredentialLog>,
require_registered_certificate: bool,
}
impl AuthGate {
pub fn new(control: Arc<dyn ControlPlane>, mode: AuthMode) -> AuthGate {
AuthGate {
control,
mode,
replay: Arc::new(ReplayGuard::default()),
unbound: Arc::new(UnboundCredentialLog::default()),
require_registered_certificate: false,
}
}
pub fn with_registered_certificate_required(mut self, require: bool) -> AuthGate {
self.require_registered_certificate = require;
self
}
pub fn with_replay_guard(mut self, replay: Arc<ReplayGuard>) -> AuthGate {
self.replay = replay;
self
}
pub fn with_unbound_log(mut self, unbound: Arc<UnboundCredentialLog>) -> AuthGate {
self.unbound = unbound;
self
}
pub fn unbound_log(&self) -> &Arc<UnboundCredentialLog> {
&self.unbound
}
fn note_unbound(&self, fingerprint: &str, method: &str, now_unix_ms: i64) {
let first = self
.unbound
.record(fingerprint, "", method, true, now_unix_ms);
if !first {
tracing::debug!(
target: AUDIT_TARGET,
event = "grpc.auth.unbound",
fingerprint,
method,
refused = true,
"a method-blind (gunnar-sshsig-v1) credential, refused again"
);
return;
}
tracing::warn!(
target: AUDIT_TARGET,
event = "grpc.auth.unbound",
fingerprint,
method,
refused = true,
"A METHOD-BLIND CREDENTIAL ({CREDENTIAL_SCHEME_V1}) was presented and REFUSED. It \
binds no method, so a header lifted off this call could be re-sent at any other \
one (GUN-001). This client has to be updated to sign {CREDENTIAL_SCHEME}. \
`Admin.Health` counts these and names every caller."
);
}
pub fn authenticate(
&self,
metadata: &tonic::metadata::MetadataMap,
now_unix_ms: i64,
) -> Result<Caller, AuthFailure> {
self.authenticate_bound(metadata, now_unix_ms, None)
}
pub fn authenticate_bound(
&self,
metadata: &tonic::metadata::MetadataMap,
now_unix_ms: i64,
called: Option<&str>,
) -> Result<Caller, AuthFailure> {
if self.mode == AuthMode::AllowAllInsecure {
return Ok(Caller::insecure());
}
let credential = Credential::from_metadata(metadata)?;
let skew = now_unix_ms - credential.issued_at_unix_ms;
if skew.abs() > MAX_CLOCK_SKEW_MS {
estate_clock::trigger::skew_refused(skew);
return Err(AuthFailure::Stale { skew_ms: skew });
}
match (&credential.method, called) {
(Some(signed), Some(called)) if signed != called => {
return Err(AuthFailure::MethodMismatch {
signed: signed.clone(),
called: called.to_owned(),
})
}
(Some(signed), None) => {
return Err(AuthFailure::MethodMismatch {
signed: signed.clone(),
called: "(the called method was not in scope)".to_owned(),
})
}
(None, _) => {
self.note_unbound(
&credential.fingerprint,
called.unwrap_or(UNKNOWN_METHOD),
now_unix_ms,
);
return Err(AuthFailure::Unbound);
}
(Some(_), Some(_)) => {}
}
let registered = self
.control
.registered_key(&credential.fingerprint)
.map_err(|err| AuthFailure::Unavailable(err.to_string()))?;
let Some(registered) = registered else {
if let Some(fed) = self.control.awaiting_enrolment(&credential.fingerprint) {
if verify_signature(&fed, &credential) {
return Err(AuthFailure::Enrolling);
}
}
return Err(AuthFailure::NotVerified);
};
if !verify_signature(®istered.openssh, &credential) {
return Err(AuthFailure::NotVerified);
}
if registered.principal_id == ANONYMOUS_PRINCIPAL_ID {
return Err(AuthFailure::Anonymous);
}
match self.replay.accept(&credential, now_unix_ms) {
Accepted::Fresh => {}
Accepted::Replayed => return Err(AuthFailure::Replayed),
Accepted::Full => return Err(AuthFailure::ReplayGuardFull),
}
Ok(Caller {
principal_id: registered.principal_id,
fingerprint: credential.fingerprint,
machine: registered.machine,
authenticated: true,
})
}
pub fn authenticate_request<T>(
&self,
request: &Request<T>,
now_unix_ms: i64,
) -> Result<Caller, AuthFailure> {
if self.mode == AuthMode::AllowAllInsecure {
return Ok(Caller::insecure());
}
if self.require_registered_certificate {
let Some(der) = crate::peer_certificate_der(request) else {
return Err(AuthFailure::Missing);
};
self.authenticate_certificate(&der)?;
}
if request.metadata().get(CREDENTIAL_METADATA_KEY).is_some() {
let called = request
.extensions()
.get::<CalledMethod>()
.map(|m| m.0.clone());
return self.authenticate_bound(request.metadata(), now_unix_ms, called.as_deref());
}
let Some(der) = crate::peer_certificate_der(request) else {
return Err(AuthFailure::Missing);
};
self.authenticate_certificate(&der)
}
fn authenticate_certificate(&self, der: &[u8]) -> Result<Caller, AuthFailure> {
let identity = self
.control
.registered_certificate(der)
.map_err(|err| AuthFailure::Unavailable(err.to_string()))?;
let Some(identity) = identity else {
return Err(AuthFailure::CertificateNotRegistered);
};
if identity.principal_id == ANONYMOUS_PRINCIPAL_ID {
return Err(AuthFailure::Anonymous);
}
Ok(Caller {
principal_id: identity.principal_id,
fingerprint: identity.fingerprint,
machine: false,
authenticated: true,
})
}
}
impl tonic::service::Interceptor for AuthGate {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
match self.authenticate_request(&request, crate::bus::now_unix_ms()) {
Ok(caller) => {
request.extensions_mut().insert(caller);
Ok(request)
}
Err(failure) => {
let detail = match &failure {
AuthFailure::Malformed(why) => why.clone(),
AuthFailure::Unavailable(why) => why.clone(),
AuthFailure::Stale { skew_ms } => format!("{skew_ms} ms"),
AuthFailure::MethodMismatch { signed, called } => {
format!("signed for {signed}, presented at {called}")
}
_ => String::new(),
};
let loud = if failure == AuthFailure::Enrolling {
None
} else if crate::quiet::unverified(failure.reason()) {
crate::quiet::loud("grpc.auth.refused:not-verified")
} else {
Some(0)
};
match loud {
Some(quietly) => tracing::warn!(
target: AUDIT_TARGET,
event = "grpc.auth.refused",
reason = failure.reason(),
detail = %detail,
quietly,
"gRPC call refused before reaching the control plane"
),
None => tracing::info!(
target: AUDIT_TARGET,
event = "grpc.auth.refused",
reason = failure.reason(),
detail = %detail,
"gRPC call refused before reaching the control plane (said at WARN this minute already)"
),
}
Err(failure.status())
}
}
}
}
pub trait AuditLedger: Send + Sync + 'static {
fn record(&self, record: AuditRecord<'_>);
}
#[derive(Debug, Clone, Copy)]
pub struct AuditRecord<'a> {
pub method: &'a str,
pub principal: &'a str,
pub fingerprint: &'a str,
pub scope: &'a str,
pub level: &'a str,
pub auth_mode: &'a str,
pub decision: &'a str,
pub reason: &'a str,
}
impl<'a> AuditRecord<'a> {
fn of(
method: &'a str,
caller: &'a Caller,
scope: &'a str,
level: GrantLevel,
mode: AuthMode,
decision: &'a str,
reason: &'a str,
) -> AuditRecord<'a> {
AuditRecord {
method,
principal: &caller.principal_id,
fingerprint: &caller.fingerprint,
scope,
level: level_name(level),
auth_mode: mode.as_str(),
decision,
reason,
}
}
}
pub(crate) fn audit_allowed(
method: &'static str,
caller: &Caller,
access: &Access,
level: GrantLevel,
mode: AuthMode,
ledger: Option<&dyn AuditLedger>,
) {
if let Some(ledger) = ledger {
let scope = access.to_string();
ledger.record(AuditRecord::of(
method, caller, &scope, level, mode, "allow", "",
));
}
tracing::info!(
target: AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
scope = %access,
level = level_name(level),
auth_mode = mode.as_str(),
decision = "allow",
"gRPC control-plane call"
);
}
pub(crate) fn audit_self(
method: &'static str,
caller: &Caller,
mode: AuthMode,
ledger: Option<&dyn AuditLedger>,
) {
let scope = format!("self:{}", caller.principal_id);
if let Some(ledger) = ledger {
ledger.record(AuditRecord::of(
method,
caller,
&scope,
GrantLevel::Unspecified,
mode,
"allow",
"the caller's own principal",
));
}
tracing::info!(
target: AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
scope = %scope,
auth_mode = mode.as_str(),
decision = "allow",
reason = "the caller's own principal",
"gRPC control-plane call on the caller's own principal"
);
}
const NARROWED_REASON: &str = "no grant on the scope; narrowed to what the ACL allows row by row";
pub(crate) fn audit_narrowed(
method: &'static str,
caller: &Caller,
access: &Access,
level: GrantLevel,
mode: AuthMode,
ledger: Option<&dyn AuditLedger>,
) {
if let Some(ledger) = ledger {
let scope = access.to_string();
ledger.record(AuditRecord::of(
method,
caller,
&scope,
level,
mode,
"narrow",
NARROWED_REASON,
));
}
tracing::info!(
target: AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
scope = %access,
level = level_name(level),
auth_mode = mode.as_str(),
decision = "narrow",
reason = NARROWED_REASON,
"gRPC control-plane listing narrowed rather than refused"
);
}
pub(crate) fn audit_denied(
method: &'static str,
caller: &Caller,
access: &Access,
level: GrantLevel,
mode: AuthMode,
why: &str,
ledger: Option<&dyn AuditLedger>,
) {
if let Some(ledger) = ledger {
let scope = access.to_string();
ledger.record(AuditRecord::of(
method, caller, &scope, level, mode, "deny", why,
));
}
tracing::warn!(
target: AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
scope = %access,
level = level_name(level),
auth_mode = mode.as_str(),
decision = "deny",
reason = why,
"gRPC control-plane call refused"
);
}
pub(crate) fn audit_override(
method: &'static str,
caller: &Caller,
access: &Access,
level: GrantLevel,
mode: AuthMode,
why: &str,
ledger: Option<&dyn AuditLedger>,
) {
let reason = format!("OVERRIDE: {why}");
if let Some(ledger) = ledger {
let scope = access.to_string();
ledger.record(AuditRecord::of(method, caller, &scope, level, mode, "allow", &reason));
}
tracing::warn!(
target: AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
scope = %access,
level = level_name(level),
auth_mode = mode.as_str(),
decision = "allow",
reason = %reason,
"gRPC control-plane call OVERRODE a guard"
);
}
const READ_VERBS: &[&str] = &[
"List", "Get", "Read", "Check", "Who", "Watch", "Verify", "Search", "Export", "Discover", "Diff",
"Blame",
];
const NOT_OPERATIONS: &[&str] = &[
"Access.WebChallenge",
"Access.WebLogin",
"Access.MachineEnrolmentStatus",
"Accounts.PurgeStatus",
"Admin.AuditTrail",
"Admin.Capacity",
"Admin.GrowStatus",
"Admin.Health",
"Admin.IssuerInfo",
"Admin.Stats",
"Admin.StorageInfo",
"Admin.Topology",
"Enterprise.Aggregates",
"Enterprise.AuditLog",
"Mirror.LogFetch",
"Mirror.LogHead",
"Mirror.ReplicationStatus",
"Mirror.SnapshotFetch",
"Mirror.Status",
"Refs.Log",
"Repos.ArchiveOut",
"Repos.GcStatus",
"Repos.RecipientKeys",
"Sso.Begin",
"Sso.Finish",
"Sso.SecondFactor",
"Usage.Activity",
"Usage.AdmitPlan",
];
#[cfg(feature = "metered")]
const METERED_NOT_OPERATIONS: &[&str] = &["Billing.ApplianceIdentity", "Billing.MintActorTicket"];
pub fn changes_state(method: &str) -> bool {
if NOT_OPERATIONS.contains(&method) {
return false;
}
#[cfg(feature = "metered")]
if METERED_NOT_OPERATIONS.contains(&method) {
return false;
}
let verb = method.rsplit_once('.').map_or(method, |(_, verb)| verb);
!READ_VERBS.iter().any(|read| verb.starts_with(read))
}
pub fn audited_methods() -> BTreeSet<&'static str> {
#[allow(unused_mut)]
let mut all: BTreeSet<&'static str> = METHODS.iter().copied().collect();
#[cfg(feature = "metered")]
all.extend(METERED_METHODS.iter().copied());
all
}
pub const UNAUTHENTICATED_METHODS: &[&str] =
&[
"Bootstrap.Enroll",
"Bootstrap.Status",
"Bootstrap.GrowStatus",
"Bootstrap.BecomeTwin",
"Bootstrap.Restore",
"Bootstrap.AssignTunnelAddress",
"Bootstrap.TwinCsr",
"Bootstrap.InstallTwinCertificate",
];
pub const UNIMPLEMENTED_METHODS: &[&str] = &[];
pub const METHODS: &[&str] = &[
"Access.AddGroupMember",
"Access.AddKey",
"Access.AddPrincipal",
"Access.Check",
"Access.WhoAmI",
"Access.AddCredential",
"Access.ListCredentials",
"Access.RevokeCredential",
"Access.RenameCredential",
"Access.CreateInvite",
"Access.ListInvites",
"Access.RevokeInvite",
"Access.CheckInvite",
"Access.WebChallenge",
"Access.WebLogin",
"Access.WebRegister",
"Access.RequestMachineEnrolment",
"Access.MachineEnrolmentStatus",
"Access.ListMachineEnrolments",
"Access.ApproveMachineEnrolment",
"Access.RefuseMachineEnrolment",
"Access.Grant",
"Access.ListGroupMembers",
"Access.ListGroupMembershipEvents",
"Access.ListGrants",
"Access.ListGroupMembers",
"Access.ListGroupMembershipEvents",
"Access.ListKeys",
"Access.ListPrincipals",
"Access.RemoveGroupMember",
"Access.RemovePrincipal",
"Access.RenamePrincipal",
"Access.Revoke",
"Access.RevokeKey",
"Accounts.Gate",
"Accounts.ListAccountGates",
"Accounts.Purge",
"Accounts.PurgeStatus",
"Accounts.Ungate",
"Sso.SetProvider",
"Sso.GetProvider",
"Sso.RemoveProvider",
"Sso.CreateScimToken",
"Sso.ListScimTokens",
"Sso.RevokeScimToken",
"Sso.ListDirectory",
"Sso.SetUserActive",
"Sso.ImportUsers",
"Sso.Discover",
"Sso.Begin",
"Sso.Finish",
"Sso.SecondFactor",
"Sso.Scim",
"Accounts.EnrollEmployee",
"Accounts.ListEmployees",
"Accounts.ClaimOperator",
"Admin.AuditTrail",
"Admin.Capacity",
"Enterprise.AuditLog",
"Enterprise.ExportAuditLog",
"Enterprise.Aggregates",
"Enterprise.RequestOperatorAccess",
"Enterprise.ListOperatorAccess",
"Enterprise.ApproveOperatorAccess",
"Enterprise.RefuseOperatorAccess",
"Enterprise.RevokeOperatorAccess",
"Enterprise.CheckName",
"Enterprise.GetNameLists",
"Enterprise.SetNameLists",
"Enterprise.ListNameReviews",
"Enterprise.DecideNameReview",
"Admin.GrowResume",
"Admin.GrowStart",
"Admin.GrowStatus",
"Admin.Health",
"Admin.IssuerInfo",
"Admin.SetTunnelAddress",
"Admin.IssueTwinCertificate",
"Admin.Stats",
"Admin.StorageInfo",
"Admin.Topology",
"Admin.VerifyChain",
"Clans.Accept",
"Clans.AnswerRepoOffer",
"Clans.Create",
"Clans.Decline",
"Clans.Dissolve",
"Clans.Get",
"Clans.Invite",
"Clans.Leave",
"Clans.List",
"Clans.OfferRepo",
"Clans.RemoveMember",
"Clans.SetAdmin",
"Clans.SetContribution",
"Events.Watch",
"Forge.Append",
"Forge.GetIssue",
"Forge.GetReview",
"Forge.ListIssues",
"Forge.ListReviews",
"Mirror.Add",
"Mirror.Remove",
"Mirror.Status",
"Mirror.AbdicateReplication",
"Mirror.DemoteReplication",
"Mirror.FlushReplication",
"Mirror.RemoveReplication",
"Mirror.ReplicationStatus",
"Mirror.ResyncReplication",
"Mirror.LogHead",
"Mirror.LogFetch",
"Mirror.SnapshotFetch",
"Mirror.LogRetain",
"Mirror.SetCredential",
"Mirror.SetReplication",
"Mirror.SetRole",
"Mirror.Sync",
"Policy.DeleteProtection",
"Policy.GetProtection",
"Policy.GetRefNamePolicy",
"Policy.ListGates",
"Policy.SetProtection",
"Policy.SetRefNamePolicy",
"PullRequests.Close",
"PullRequests.Create",
"PullRequests.Get",
"PullRequests.List",
"PullRequests.Merge",
"Refs.Blame",
"Refs.Diff",
"Refs.Get",
"Refs.List",
"Refs.Log",
"Refs.ReadLfsObject",
"Refs.ReadObject",
"Refs.Search",
"Repos.ArchiveOut",
"Repos.Create",
"Repos.CreateNamespace",
"Repos.Delete",
"Repos.DeleteNamespace",
"Repos.Gc",
"Repos.GcStatus",
"Repos.Get",
"Repos.List",
"Repos.ListDeleted",
"Repos.ListNamespaces",
"Repos.ListOrphans",
"Repos.Purge",
"Repos.RecipientKeys",
"Repos.ReclaimOrphan",
"Repos.SetDefaultBranch",
"Repos.SetEncrypted",
"Repos.SetNamespaceSettings",
"Repos.SetVisibility",
"Repos.Undelete",
"Usage.Activity",
"Usage.AdmitPlan",
"Usage.Read",
"Usage.ReadAll",
"UsageBatch.ReadMany",
"Webhooks.AddWebhook",
"Webhooks.ListWebhooks",
"Webhooks.RemoveWebhook",
];
#[cfg(feature = "metered")]
pub const METERED_METHODS: &[&str] = &[
"Billing.ApplianceIdentity",
"Billing.MintActorTicket",
"Entitlement.Get",
"Entitlement.List",
"Entitlement.Set",
"Sealed.Get",
"Sealed.Put",
];
pub const TWIN_ANSWER_NAMESPACE: &str = "gunnar-twin";
const TWIN_ANSWER_V1: &str = "gunnar-log-head-v1";
const TWIN_ANSWER_V2: &str = "gunnar-log-head-v2";
pub fn twin_answer_challenge(nonce: &str, log_id: &str, first_seq: u64, head_seq: u64) -> String {
twin_answer_challenge_at(nonce, log_id, first_seq, head_seq, 0)
}
pub fn twin_answer_challenge_at(nonce: &str, log_id: &str, first_seq: u64, head_seq: u64, generation: u64) -> String {
if generation == 0 {
format!("{TWIN_ANSWER_V1}\n{nonce}\n{log_id}\n{first_seq}\n{head_seq}\n")
} else {
format!("{TWIN_ANSWER_V2}\n{nonce}\n{log_id}\n{first_seq}\n{head_seq}\n{generation}\n")
}
}
pub fn sign_twin_answer(
key: &ssh_key::PrivateKey,
nonce: &str,
log_id: &str,
first_seq: u64,
head_seq: u64,
) -> Result<String, ssh_key::Error> {
sign_twin_answer_at(key, nonce, log_id, first_seq, head_seq, 0)
}
pub fn sign_twin_answer_at(
key: &ssh_key::PrivateKey,
nonce: &str,
log_id: &str,
first_seq: u64,
head_seq: u64,
generation: u64,
) -> Result<String, ssh_key::Error> {
let message = twin_answer_challenge_at(nonce, log_id, first_seq, head_seq, generation);
let signature = key.sign(TWIN_ANSWER_NAMESPACE, ssh_key::HashAlg::Sha512, message.as_bytes())?;
Ok(armor_to_line(&signature.to_pem(ssh_key::LineEnding::LF)?))
}
pub fn verify_twin_answer(
pinned_public_key: &str,
signature_line: &str,
nonce: &str,
log_id: &str,
first_seq: u64,
head_seq: u64,
) -> bool {
verify_twin_answer_at(pinned_public_key, signature_line, nonce, log_id, first_seq, head_seq, 0)
}
pub fn verify_twin_answer_at(
pinned_public_key: &str,
signature_line: &str,
nonce: &str,
log_id: &str,
first_seq: u64,
head_seq: u64,
generation: u64,
) -> bool {
let Ok(key) = ssh_key::PublicKey::from_openssh(pinned_public_key) else {
return false;
};
let armor = line_to_armor(signature_line);
let Ok(signature) = ssh_key::SshSig::from_pem(armor.as_bytes()) else {
return false;
};
if signature.namespace() != TWIN_ANSWER_NAMESPACE {
return false;
}
let message = twin_answer_challenge_at(nonce, log_id, first_seq, head_seq, generation);
key.verify(TWIN_ANSWER_NAMESPACE, message.as_bytes(), &signature).is_ok()
}
#[cfg(test)]
mod twin_answer_tests {
use super::*;
fn key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::random(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng), ssh_key::Algorithm::Ed25519).unwrap()
}
fn pub_line(k: &ssh_key::PrivateKey) -> String {
k.public_key().to_openssh().unwrap()
}
#[test]
fn a_twin_answer_verifies_only_for_the_pinned_key_this_nonce_and_these_numbers() {
let primary = key();
let pinned = pub_line(&primary);
let sig = sign_twin_answer(&primary, "nonce-1", "abc123", 1, 42).unwrap();
assert!(verify_twin_answer(&pinned, &sig, "nonce-1", "abc123", 1, 42), "the real answer verifies");
assert!(!verify_twin_answer(&pub_line(&key()), &sig, "nonce-1", "abc123", 1, 42), "another box's key must not verify");
assert!(!verify_twin_answer(&pinned, &sig, "nonce-2", "abc123", 1, 42), "an answer for another poll is not this poll's");
assert!(!verify_twin_answer(&pinned, &sig, "nonce-1", "def456", 1, 42), "the log identity is signed");
assert!(!verify_twin_answer(&pinned, &sig, "nonce-1", "abc123", 2, 42), "first_seq is signed");
assert!(!verify_twin_answer(&pinned, &sig, "nonce-1", "abc123", 1, 43), "head_seq is signed");
assert!(!verify_twin_answer(&pinned, "", "nonce-1", "abc123", 1, 42), "an unsigned answer is not signed");
}
#[test]
fn the_twin_namespace_and_the_credential_namespace_do_not_cross() {
assert_ne!(TWIN_ANSWER_NAMESPACE, SSHSIG_NAMESPACE);
let primary = key();
let pinned = pub_line(&primary);
let message = twin_answer_challenge("n", "id", 1, 2);
let wrong = primary.sign(SSHSIG_NAMESPACE, ssh_key::HashAlg::Sha512, message.as_bytes()).unwrap();
let line = armor_to_line(&wrong.to_pem(ssh_key::LineEnding::LF).unwrap());
assert!(!verify_twin_answer(&pinned, &line, "n", "id", 1, 2), "a signature from the credential namespace is not an answer");
let answer = sign_twin_answer(&primary, "n", "id", 1, 2).unwrap();
let armor = line_to_armor(&answer);
let sig = ssh_key::SshSig::from_pem(armor.as_bytes()).unwrap();
assert_ne!(sig.namespace(), SSHSIG_NAMESPACE);
}
}
#[cfg(test)]
mod changes_state_tests {
#[test]
fn a_read_is_not_an_operation_and_a_change_is() {
for read in ["Access.ListPrincipals", "Access.WhoAmI", "Repos.List", "Refs.Log", "Admin.Health", "Access.WebLogin", "Sso.Finish"] {
assert!(!super::changes_state(read), "{read} is not an operation");
}
for write in ["Access.AddPrincipal", "Access.AddKey", "Repos.Create", "Repos.Delete", "Repos.SetVisibility", "Policy.SetProtection", "Mirror.LogRetain", "Sso.Scim"] {
assert!(super::changes_state(write), "{write} is an operation");
}
for listed in super::NOT_OPERATIONS {
assert!(super::audited_methods().contains(listed), "{listed} names no method");
}
#[cfg(feature = "metered")]
for listed in super::METERED_NOT_OPERATIONS {
assert!(super::audited_methods().contains(listed), "{listed} names no method");
assert!(!super::changes_state(listed), "{listed} is a read");
}
}
}