crates/nornir-auth-sshsig/src/lib.rs
//! # nornir-auth-sshsig — per-call SSH-signature credentials, product-neutral
//!
//! A caller proves, on EVERY gRPC call, that it holds the private half of an
//! OpenSSH key the server already knows, by signing a short single-use
//! challenge with it. The signature is an **SSHSIG** (the format
//! `ssh-keygen -Y sign` produces), so the credential can come from
//! `ssh-agent`, `ssh-keygen` or `ssh-key` in-process — no new key material, no
//! shared secret, and revoking a caller is removing its public key.
//!
//! ```text
//! metadata: <METADATA_KEY>: <SCHEME> <fingerprint> <issued_at_ms> <nonce> <method> <armor>
//! signed: <PREAMBLE>\n<fingerprint>\n<issued_at_ms>\n<nonce>\n<method>\n
//! ```
//!
//! This crate is the scheme and nothing product-specific. It was gunnar's
//! (`gunnar-grpc/src/auth.rs`, gunnar's wire format byte for byte — see
//! [`Scheme`]) and moved to edda (workspace RULE 2: common code lives in
//! edda/Skidbladnir) when monetize's control API needed the same check for the
//! shared operator console (owner 2026-09-28: "SSHSIG per call, exactly
//! gunnar's model"). Two copies of a verifier is the twin the house forbids:
//! the day one copy gains a check the other misses, a credential refused by
//! one product is accepted by the other.
//!
//! What a PRODUCT supplies ([`Scheme`]): its metadata key, its scheme token
//! and its **sshsig namespace**. The namespace is bound into what is signed, so
//! a credential minted for gunnar never verifies at monetize, a signed commit
//! (`git`) never verifies as either, and the reverse.
//!
//! What the crate supplies: the [`Credential`] (parse / render / the bytes it
//! signs), [`sign`], the one [`verify_signature`], the [`ReplayGuard`], and
//! [`check`] — the whole five-step verification for a product that needs no
//! more than "which of these keys signed this call for this method, now, once".
//! gunnar keeps its own gate (its ACL table, audit trail, unbound-credential
//! log) on top of the primitives; monetize uses [`check`].
/// The key types a caller signs with and a server verifies against, so a
/// consumer needs no second `ssh-key` dependency to name them.
pub use ssh_key;
use std::cmp::Reverse;
use std::collections::hash_map::RandomState;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
use std::marker::PhantomData;
use std::sync::Mutex;
/// **A product's credential scheme** — the constants that make its
/// credentials its own. Implemented by a unit type per product.
pub trait Scheme:
Clone + Copy + std::fmt::Debug + PartialEq + Eq + Default + Send + Sync + 'static
{
/// The metadata key the credential travels in (never `authorization`:
/// that name belongs to bearer/basic schemes and proxies rewrite it).
const METADATA_KEY: &'static str;
/// The scheme token of a METHOD-BOUND credential.
const SCHEME: &'static str;
/// The scheme token of a method-blind credential, when the product still
/// parses one (gunnar's v1); `None` = never accepted.
const SCHEME_UNBOUND: Option<&'static str>;
/// The sshsig namespace signatures are made and checked under.
const NAMESPACE: &'static str;
/// The first line of the method-bound challenge.
const PREAMBLE: &'static str;
/// The first line of the method-blind challenge (unused when
/// [`Scheme::SCHEME_UNBOUND`] is `None`).
const PREAMBLE_UNBOUND: &'static str;
/// The error a parse answers; a product maps [`CredentialError`] into its own.
type Error: From<CredentialError>;
}
/// How far a credential's `issued_at` may be from the server's clock, either
/// way: enough for an unsynchronised laptop, short enough that the
/// [`ReplayGuard`] stays small.
pub const MAX_CLOCK_SKEW_MS: i64 = 5 * 60 * 1000;
/// The shortest nonce accepted: a nonce is what makes a credential single-use.
pub const MIN_NONCE_LEN: usize = 16;
/// The hash the SSHSIG is made with.
pub const HASH_ALG: ssh_key::HashAlg = ssh_key::HashAlg::Sha512;
/// Why a credential could not even be read. Products map it into their own
/// refusal type ([`Scheme::Error`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialError {
/// No credential in the request.
Missing,
/// Present but unparseable.
Malformed(String),
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredentialError::Missing => write!(f, "no credential"),
CredentialError::Malformed(why) => write!(f, "malformed credential: {why}"),
}
}
}
/// A parsed, not-yet-verified credential of scheme `S`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Credential<S: Scheme> {
/// `SHA256:<base64>` of the signing key.
pub fingerprint: String,
pub issued_at_unix_ms: i64,
pub nonce: String,
/// The method signed FOR, as `Service.Method`; `None` = a method-blind
/// credential ([`Scheme::SCHEME_UNBOUND`]).
pub method: Option<String>,
/// The sshsig armour on one line ([`armor_to_line`]).
pub signature_line: String,
scheme: PhantomData<S>,
}
/// **The one writer of the method-blind challenge bytes.**
pub fn challenge<S: Scheme>(fingerprint: &str, issued_at_unix_ms: i64, nonce: &str) -> String {
format!(
"{}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n",
S::PREAMBLE_UNBOUND
)
}
/// **The one writer of the method-bound challenge bytes.** The method is a
/// whole line ending in `\n`, so no method name can be a prefix of another's
/// bytes.
pub fn challenge_bound<S: Scheme>(
fingerprint: &str,
issued_at_unix_ms: i64,
nonce: &str,
method: &str,
) -> String {
format!(
"{}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n{method}\n",
S::PREAMBLE
)
}
/// `/pkg.v1.Service/Method` → `Service.Method`; `None` for anything that is
/// not a two-segment gRPC path. The proto package is dropped: it carries no
/// information inside one product and keeps the header short.
pub fn method_name_from_path(path: &str) -> Option<String> {
let mut parts = path.trim_start_matches('/').split('/');
let service = parts.next().filter(|s| !s.is_empty())?;
let method = parts.next().filter(|s| !s.is_empty())?;
if parts.next().is_some() {
return None;
}
let service = service.rsplit('.').next().unwrap_or(service);
Some(format!("{service}.{method}"))
}
/// PEM armour → one whitespace-free header field (`\n` → `|`, ` ` → `~`;
/// neither occurs in base64 or a PEM label, so the mapping is a bijection).
pub fn armor_to_line(armor: &str) -> String {
armor.trim_end().replace('\n', "|").replace(' ', "~")
}
/// The inverse of [`armor_to_line`].
pub fn line_to_armor(line: &str) -> String {
let mut armor = line.replace('~', " ").replace('|', "\n");
if !armor.ends_with('\n') {
armor.push('\n');
}
armor
}
impl<S: Scheme> Credential<S> {
/// A credential from its parts (tests, and a signer that already holds them).
pub fn new(
fingerprint: String,
issued_at_unix_ms: i64,
nonce: String,
method: Option<String>,
signature_line: String,
) -> Self {
Credential {
fingerprint,
issued_at_unix_ms,
nonce,
method,
signature_line,
scheme: PhantomData,
}
}
/// Render the metadata value a client sends. A bound credential renders
/// [`Scheme::SCHEME`], a blind one the unbound token.
pub fn to_header_value(&self) -> String {
match &self.method {
Some(method) => format!(
"{} {} {} {} {} {}",
S::SCHEME,
self.fingerprint,
self.issued_at_unix_ms,
self.nonce,
method,
self.signature_line
),
None => format!(
"{} {} {} {} {}",
S::SCHEME_UNBOUND.unwrap_or(S::SCHEME),
self.fingerprint,
self.issued_at_unix_ms,
self.nonce,
self.signature_line
),
}
}
/// Parse a metadata value. Shape only; nothing here is trusted.
pub fn parse(value: &str) -> Result<Self, S::Error> {
Self::parse_plain(value).map_err(S::Error::from)
}
fn parse_plain(value: &str) -> Result<Self, CredentialError> {
let malformed = |m: String| CredentialError::Malformed(m);
let usage = || {
malformed(format!(
"expected `{} <fingerprint> <issued_at_ms> <nonce> <method> <signature>`",
S::SCHEME
))
};
let mut fields = value.split_whitespace();
let scheme = fields.next().unwrap_or_default();
let bound = if scheme == S::SCHEME {
true
} else if S::SCHEME_UNBOUND == Some(scheme) {
false
} else {
return Err(malformed(match S::SCHEME_UNBOUND {
Some(v1) => format!(
"credential scheme {scheme:?} is neither {:?} nor {v1:?}",
S::SCHEME
),
None => format!("credential scheme {scheme:?} is not {:?}", S::SCHEME),
}));
};
let (Some(fingerprint), Some(issued_at), Some(nonce)) =
(fields.next(), fields.next(), fields.next())
else {
return Err(usage());
};
let method = match bound {
true => Some(fields.next().ok_or_else(|| {
malformed(format!(
"a {} credential must name the method it was signed for",
S::SCHEME
))
})?),
false => None,
};
let Some(signature) = fields.next() else {
return Err(usage());
};
if fields.next().is_some() {
return Err(malformed("trailing data after the signature".to_owned()));
}
if !fingerprint.starts_with("SHA256:") {
return Err(malformed(format!(
"{fingerprint:?} is not a SHA256: fingerprint"
)));
}
let issued_at_unix_ms = issued_at
.parse::<i64>()
.map_err(|_| malformed(format!("{issued_at:?} is not a timestamp")))?;
if nonce.len() < MIN_NONCE_LEN {
return Err(malformed(format!(
"the nonce is {} characters; at least {MIN_NONCE_LEN} are required, or two honest callers collide",
nonce.len()
)));
}
Ok(Credential::new(
fingerprint.to_owned(),
issued_at_unix_ms,
nonce.to_owned(),
method.map(str::to_owned),
signature.to_owned(),
))
}
/// Pull the credential out of a request's metadata ([`Scheme::METADATA_KEY`]).
#[cfg(feature = "tonic")]
pub fn from_metadata(metadata: &tonic::metadata::MetadataMap) -> Result<Self, S::Error> {
let raw = metadata
.get(S::METADATA_KEY)
.ok_or(S::Error::from(CredentialError::Missing))?;
let text = raw.to_str().map_err(|_| {
S::Error::from(CredentialError::Malformed(
"the credential is not ASCII".to_owned(),
))
})?;
Self::parse(text)
}
/// The bytes this credential claims a signature over.
pub fn challenge(&self) -> String {
match &self.method {
Some(method) => challenge_bound::<S>(
&self.fingerprint,
self.issued_at_unix_ms,
&self.nonce,
method,
),
None => challenge::<S>(&self.fingerprint, self.issued_at_unix_ms, &self.nonce),
}
}
}
/// **Sign** a credential with an OpenSSH private key: bound to `method`
/// (`Service.Method`) when given, method-blind otherwise.
pub fn sign<S: Scheme>(
key: &ssh_key::PrivateKey,
issued_at_unix_ms: i64,
nonce: &str,
method: Option<&str>,
) -> Result<Credential<S>, ssh_key::Error> {
let fingerprint = key
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string();
let message = match method {
Some(method) => challenge_bound::<S>(&fingerprint, issued_at_unix_ms, nonce, method),
None => challenge::<S>(&fingerprint, issued_at_unix_ms, nonce),
};
let signature = key.sign(S::NAMESPACE, HASH_ALG, message.as_bytes())?;
let armor = signature.to_pem(ssh_key::LineEnding::LF)?;
Ok(Credential::new(
fingerprint,
issued_at_unix_ms,
nonce.to_owned(),
method.map(str::to_owned),
armor_to_line(&armor),
))
}
/// **The one verifier**: does `credential`'s signature verify against this
/// OpenSSH public key line, under the scheme's namespace? The fingerprint is
/// re-derived from the key handed in, so a signature by key A is never
/// accepted as key B even when a table hands back the wrong row.
pub fn verify_signature<S: Scheme>(openssh_public_key: &str, credential: &Credential<S>) -> bool {
let Ok(key) = ssh_key::PublicKey::from_openssh(openssh_public_key) else {
return false;
};
if key.fingerprint(ssh_key::HashAlg::Sha256).to_string() != credential.fingerprint {
return false;
}
let armor = line_to_armor(&credential.signature_line);
let Ok(signature) = ssh_key::SshSig::from_pem(armor.as_bytes()) else {
return false;
};
// Checked by `verify` too; visible here because a `git` signature must
// never pass as a control-plane credential.
if signature.namespace() != S::NAMESPACE {
return false;
}
key.verify(S::NAMESPACE, credential.challenge().as_bytes(), &signature)
.is_ok()
}
/// What [`ReplayGuard::accept`] decided — three answers, because "I will not
/// remember this" and "I have seen this" are opposite facts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accepted {
/// Never seen, and now remembered.
Fresh,
/// Seen before, inside the freshness window.
Replayed,
/// Refused rather than remembered: THIS key already holds as many
/// still-presentable credentials as one key may, or the whole guard is
/// full. Never answered by forgetting one (a forgotten credential can be
/// replayed).
Full,
}
/// How many single-use credentials one server remembers at a time, all keys
/// together, before only the per-key floor ([`REPLAY_KEY_FLOOR`]) is still
/// let in from a reserve of an eighth more. About 40 bytes per credential (a
/// keyed 64-bit hash in a set, and its expiry in a heap): measured 46 MiB of
/// RSS for the 1 179 648 a full guard holds, 346 ns per accept, and handed
/// back once they expire. Only authenticated keys flooding for minutes get
/// there; nothing is allocated until used.
///
/// 0.1.1 held 65 536 strings in one pool, about 218 signed calls per second
/// from all keys together: GUN-PERF's 10 s console burst filled it and every
/// caller got `Full` for five minutes.
pub const DEFAULT_REPLAY_CAPACITY: usize = 1 << 20;
/// How many still-presentable credentials ONE key may hold in a
/// [`ReplayGuard::default`]: 524 288, which is GUN-PERF's measured 10 s
/// console burst (171 086 calls) followed by the console's own throttle
/// (500/s) for the rest of the window, with room to spare, or about 1 700
/// calls per second sustained. A key past it is refused on its own.
pub const DEFAULT_REPLAY_PER_KEY: usize = 1 << 19;
/// The credentials every key may still hold when the guard's main pool is
/// full, from a reserve of an eighth of the capacity: a flood by a few keys
/// leaves the rest working.
pub const REPLAY_KEY_FLOOR: usize = 4_096;
/// **Remembers accepted `(fingerprint, nonce)` pairs so each credential is
/// used once.**
///
/// - **Exactly as long as it can be presented.** A credential is presentable
/// while `|now − issued_at| ≤` [`MAX_CLOCK_SKEW_MS`], so it is held until
/// `issued_at + MAX_CLOCK_SKEW_MS` has passed, to the millisecond, by a
/// min-heap of expiries (0.1.1 held it one skew from when it was ACCEPTED: a future-dated one was
/// forgotten while still presentable, a past-dated one held for nothing).
/// The caller checks the window before the guard ([`check`] does); a stale
/// credential handed in anyway is held until the next call that is later.
/// Exact under clock steps: an entry goes when ITS expiry has passed.
/// - **Per key.** Each fingerprint may hold up to its own cap; past it, that
/// key gets [`Accepted::Full`] and no other does. When the whole pool is
/// full, every key may still hold [`REPLAY_KEY_FLOOR`] from a reserve.
/// - **Bounded.** At most `capacity` plus an eighth, entries of a keyed 64-bit
/// hash (SipHash with a per-guard random key): a collision can only refuse
/// an honest call as a replay, never accept one, and at a million entries
/// it is a 1 in 10^13 event.
/// - **Never evicts** a still-presentable entry to make room.
#[derive(Debug)]
pub struct ReplayGuard {
inner: Mutex<ReplayInner>,
capacity: usize,
per_key: usize,
reserve: usize,
floor: usize,
hasher: RandomState,
}
#[derive(Debug, Default)]
struct ReplayInner {
/// The keyed hash of every held `(fingerprint, nonce)`.
seen: HashSet<u64, BuildHasherDefault<Prehashed>>,
/// How many each key (by the keyed hash of its fingerprint) holds.
per_key: HashMap<u64, usize, BuildHasherDefault<Prehashed>>,
/// `(expires_ms, nonce, key)`, soonest first.
expiry: BinaryHeap<Reverse<(i64, u64, u64)>>,
}
/// The set's hasher for values that are already keyed SipHash outputs.
#[derive(Debug, Default, Clone, Copy)]
struct Prehashed(u64);
impl Hasher for Prehashed {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.0 = self.0.rotate_left(8) ^ u64::from(b);
}
}
fn write_u64(&mut self, n: u64) {
self.0 = n;
}
}
impl ReplayInner {
/// Forget everything whose last presentable instant is before `now`.
fn sweep(&mut self, now_unix_ms: i64) {
let mut dropped = false;
while let Some(Reverse((expires_ms, nonce, key))) = self.expiry.peek().copied() {
if expires_ms >= now_unix_ms {
break;
}
self.expiry.pop();
self.seen.remove(&nonce);
if let Some(held) = self.per_key.get_mut(&key) {
*held -= 1;
if *held == 0 {
self.per_key.remove(&key);
}
}
dropped = true;
}
// Hand the memory of a drained burst back.
if dropped && self.seen.capacity() > 4096 && self.seen.len() < self.seen.capacity() / 4 {
self.seen.shrink_to(self.seen.len() * 2);
self.expiry.shrink_to(self.expiry.len() * 2);
self.per_key.shrink_to_fit();
}
}
}
impl Default for ReplayGuard {
fn default() -> Self {
ReplayGuard::with_limits(DEFAULT_REPLAY_CAPACITY, DEFAULT_REPLAY_PER_KEY)
}
}
impl ReplayGuard {
/// A guard of `capacity` credentials, of which one key may hold
/// `capacity` or [`DEFAULT_REPLAY_PER_KEY`], whichever is smaller.
pub fn new(capacity: usize) -> ReplayGuard {
ReplayGuard::with_limits(capacity, capacity.min(DEFAULT_REPLAY_PER_KEY))
}
/// A guard of `capacity` credentials in all, `per_key` for any one key,
/// and a reserve of an eighth of `capacity` from which every key may
/// still hold [`REPLAY_KEY_FLOOR`] once the main pool is full.
pub fn with_limits(capacity: usize, per_key: usize) -> ReplayGuard {
let capacity = capacity.max(1);
let reserve = capacity / 8;
ReplayGuard {
inner: Mutex::new(ReplayInner::default()),
capacity,
per_key: per_key.clamp(1, capacity),
reserve,
floor: REPLAY_KEY_FLOOR.min(reserve),
hasher: RandomState::new(),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, ReplayInner> {
// A poisoned lock never becomes "allow".
match self.inner.lock() {
Ok(inner) => inner,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Accept this credential once.
pub fn accept<S: Scheme>(&self, credential: &Credential<S>, now_unix_ms: i64) -> Accepted {
let nonce = self
.hasher
.hash_one((1u8, &credential.fingerprint, &credential.nonce));
let key = self.hasher.hash_one((0u8, &credential.fingerprint));
let mut inner = self.lock();
inner.sweep(now_unix_ms);
// Replay before capacity: a replay must never be reported as load.
if inner.seen.contains(&nonce) {
return Accepted::Replayed;
}
let held = inner.per_key.get(&key).copied().unwrap_or(0);
if held >= self.per_key {
return Accepted::Full;
}
let total = inner.seen.len();
if total >= self.capacity && !(held < self.floor && total < self.capacity + self.reserve) {
return Accepted::Full;
}
// Held until the last instant it can be presented has passed.
let expires_ms = credential
.issued_at_unix_ms
.saturating_add(MAX_CLOCK_SKEW_MS)
.max(now_unix_ms);
inner.expiry.push(Reverse((expires_ms, nonce, key)));
*inner.per_key.entry(key).or_insert(0) += 1;
inner.seen.insert(nonce);
Accepted::Fresh
}
/// How many credentials are remembered now.
pub fn len(&self) -> usize {
self.lock().seen.len()
}
/// How many credentials signed by this fingerprint are remembered now.
pub fn held_for(&self, fingerprint: &str) -> usize {
let key = self.hasher.hash_one((0u8, fingerprint));
self.lock().per_key.get(&key).copied().unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
/// Why [`check`] refused a call. The wire answer should stay coarse (never
/// say whether a fingerprint is known); the variant is for the audit line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Missing,
Malformed(String),
/// Outside [`MAX_CLOCK_SKEW_MS`].
Stale {
skew_ms: i64,
},
/// A method-blind credential where only bound ones are taken.
Unbound,
/// Signed for one method, presented at another.
MethodMismatch {
signed: String,
called: String,
},
/// The request's path is not a gRPC method.
NoMethod,
/// No key this server knows, or the signature did not verify.
NotVerified,
Replayed,
ReplayGuardFull,
}
impl Refusal {
/// The short reason for an audit record.
pub fn reason(&self) -> &'static str {
match self {
Refusal::Missing => "missing",
Refusal::Malformed(_) => "malformed",
Refusal::Stale { .. } => "stale",
Refusal::Unbound => "unbound",
Refusal::MethodMismatch { .. } => "method-mismatch",
Refusal::NoMethod => "no-method",
Refusal::NotVerified => "not-verified",
Refusal::Replayed => "replayed",
Refusal::ReplayGuardFull => "replay-guard-full",
}
}
}
impl From<CredentialError> for Refusal {
fn from(e: CredentialError) -> Refusal {
match e {
CredentialError::Missing => Refusal::Missing,
CredentialError::Malformed(m) => Refusal::Malformed(m),
}
}
}
/// **The whole verification, for a product with a flat key set.**
///
/// `header` is the raw metadata value (or `None`), `path` the request's
/// `:path`, `keys` answers the OpenSSH public key line registered for a
/// fingerprint. In order: parse; bound only (a blind credential is refused);
/// inside the clock window; signed for THIS method; a known key whose
/// signature verifies; not seen before. Answers the verified fingerprint.
///
/// The replay guard is consulted LAST, so a forged or misdirected credential
/// never occupies a slot.
pub fn check<S: Scheme>(
header: Option<&str>,
path: &str,
now_unix_ms: i64,
keys: impl Fn(&str) -> Option<String>,
guard: &ReplayGuard,
) -> Result<String, Refusal>
where
S::Error: Into<Refusal>,
{
let raw = header.ok_or(Refusal::Missing)?;
let cred = Credential::<S>::parse(raw).map_err(Into::into)?;
let Some(signed) = cred.method.clone() else {
return Err(Refusal::Unbound);
};
let skew_ms = now_unix_ms - cred.issued_at_unix_ms;
if skew_ms.abs() > MAX_CLOCK_SKEW_MS {
return Err(Refusal::Stale { skew_ms });
}
let called = method_name_from_path(path).ok_or(Refusal::NoMethod)?;
if signed != called {
return Err(Refusal::MethodMismatch { signed, called });
}
let key = keys(&cred.fingerprint).ok_or(Refusal::NotVerified)?;
if !verify_signature(&key, &cred) {
return Err(Refusal::NotVerified);
}
match guard.accept(&cred, now_unix_ms) {
Accepted::Fresh => Ok(cred.fingerprint),
Accepted::Replayed => Err(Refusal::Replayed),
Accepted::Full => Err(Refusal::ReplayGuardFull),
}
}
/// The `SHA256:…` fingerprint of an OpenSSH public key line, or `None` when
/// the line is not a key.
pub fn fingerprint_of(openssh_public_key: &str) -> Option<String> {
ssh_key::PublicKey::from_openssh(openssh_public_key.trim())
.ok()
.map(|k| k.fingerprint(ssh_key::HashAlg::Sha256).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
struct A;
impl Scheme for A {
const METADATA_KEY: &'static str = "a-authorization";
const SCHEME: &'static str = "a-sshsig-v2";
const SCHEME_UNBOUND: Option<&'static str> = Some("a-sshsig-v1");
const NAMESPACE: &'static str = "a-grpc";
const PREAMBLE: &'static str = "a-grpc-auth-v2";
const PREAMBLE_UNBOUND: &'static str = "a-grpc-auth-v1";
type Error = Refusal;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
struct B;
impl Scheme for B {
const METADATA_KEY: &'static str = "b-authorization";
const SCHEME: &'static str = "b-sshsig-v1";
const SCHEME_UNBOUND: Option<&'static str> = None;
const NAMESPACE: &'static str = "b-grpc";
const PREAMBLE: &'static str = "b-grpc-auth-v1";
const PREAMBLE_UNBOUND: &'static str = "";
type Error = Refusal;
}
fn key(seed: u8) -> ssh_key::PrivateKey {
let kp = ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]);
ssh_key::PrivateKey::from(kp)
}
fn line(k: &ssh_key::PrivateKey) -> String {
k.public_key().to_openssh().unwrap()
}
const NOW: i64 = 1_790_000_000_000;
const NONCE: &str = "0123456789abcdef0123";
const PATH: &str = "/monetize.v1.Products/List";
#[test]
fn a_signed_call_verifies_once_for_its_method_only() {
let k = key(1);
let c = sign::<B>(&k, NOW, NONCE, Some("Products.List")).unwrap();
let header = c.to_header_value();
assert_eq!(
Credential::<B>::parse(&header).unwrap(),
c,
"render/parse round-trips"
);
let keys = |fp: &str| (fp == c.fingerprint).then(|| line(&k));
let g = ReplayGuard::default();
assert_eq!(
check::<B>(Some(&header), PATH, NOW, keys, &g),
Ok(c.fingerprint.clone())
);
assert_eq!(
check::<B>(Some(&header), PATH, NOW, keys, &g),
Err(Refusal::Replayed),
"single use"
);
let c2 = sign::<B>(&k, NOW, "another-nonce-0000", Some("Products.List")).unwrap();
assert_eq!(
check::<B>(
Some(&c2.to_header_value()),
"/monetize.v1.Purchase/Start",
NOW,
keys,
&g
),
Err(Refusal::MethodMismatch {
signed: "Products.List".into(),
called: "Purchase.Start".into()
})
);
assert_eq!(g.len(), 1, "a refused call never occupies a replay slot");
}
#[test]
fn a_stranger_a_stale_and_a_blind_credential_are_refused() {
let (k, other) = (key(1), key(2));
let g = ReplayGuard::default();
let c = sign::<B>(&k, NOW, NONCE, Some("Products.List")).unwrap();
let only_other = |_: &str| Some(line(&other));
assert_eq!(
check::<B>(Some(&c.to_header_value()), PATH, NOW, only_other, &g),
Err(Refusal::NotVerified),
"another key's line never verifies it"
);
assert_eq!(
check::<B>(Some(&c.to_header_value()), PATH, NOW, |_| None, &g),
Err(Refusal::NotVerified),
"an unknown key"
);
let keys = |_: &str| Some(line(&k));
let late = NOW + MAX_CLOCK_SKEW_MS + 1;
assert!(matches!(
check::<B>(Some(&c.to_header_value()), PATH, late, keys, &g),
Err(Refusal::Stale { .. })
));
let blind = sign::<A>(&k, NOW, NONCE, None).unwrap();
assert_eq!(
check::<A>(Some(&blind.to_header_value()), PATH, NOW, keys, &g),
Err(Refusal::Unbound),
"check takes bound credentials only"
);
assert_eq!(check::<B>(None, PATH, NOW, keys, &g), Err(Refusal::Missing));
}
/// The namespace is the product: a credential of scheme A, even rendered
/// under B's scheme token, does not verify under B.
#[test]
fn one_products_credential_never_verifies_at_another() {
let k = key(3);
let a = sign::<A>(&k, NOW, NONCE, Some("Products.List")).unwrap();
let smuggled = Credential::<B>::new(
a.fingerprint.clone(),
a.issued_at_unix_ms,
a.nonce.clone(),
a.method.clone(),
a.signature_line.clone(),
);
assert!(verify_signature(&line(&k), &a));
assert!(
!verify_signature(&line(&k), &smuggled),
"A's signature is not B's"
);
assert!(
Credential::<B>::parse(&a.to_header_value()).is_err(),
"A's scheme token is not B's"
);
}
#[test]
fn the_armor_mapping_is_a_bijection_and_paths_name_methods() {
let armor = "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n";
assert_eq!(line_to_armor(&armor_to_line(armor)), armor);
assert!(!armor_to_line(armor).contains(char::is_whitespace));
assert_eq!(
method_name_from_path("/gunnar.v1.Access/AddKey").as_deref(),
Some("Access.AddKey")
);
assert_eq!(method_name_from_path("/a/b/c"), None);
assert_eq!(
fingerprint_of(&line(&key(4))),
Some(
key(4)
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string()
)
);
assert_eq!(fingerprint_of("not a key"), None);
}
#[test]
fn a_full_guard_refuses_and_forgets_only_the_expired() {
let k = key(5);
let g = ReplayGuard::new(1);
let c1 = sign::<B>(&k, NOW, "nonce-number-one-000", Some("X.Y")).unwrap();
let c2 = sign::<B>(&k, NOW, "nonce-number-two-000", Some("X.Y")).unwrap();
assert_eq!(g.accept(&c1, NOW), Accepted::Fresh);
assert_eq!(g.accept(&c2, NOW), Accepted::Full);
assert_eq!(
g.accept(&c1, NOW),
Accepted::Replayed,
"replay before capacity"
);
assert_eq!(
g.accept(&c2, NOW + MAX_CLOCK_SKEW_MS + 1),
Accepted::Fresh,
"the expired one is forgotten"
);
}
}