pub const HEADER: &str = "# gunnar-crypt members v1 — public keys only; the key ring in .gunnar-crypt/keys is wrapped to every key below.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyKind {
SshEd25519,
SshRsa,
Age,
AgePq,
}
impl KeyKind {
pub fn stanza_type(self) -> &'static str {
match self {
KeyKind::SshEd25519 => "ssh-ed25519",
KeyKind::SshRsa => "ssh-rsa",
KeyKind::Age => "X25519",
KeyKind::AgePq => PQ_STANZA,
}
}
}
pub const PQ_STANZA: &str = "mlkem768x25519";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Level {
Classic,
#[default]
QuantumSafe,
}
impl std::str::FromStr for Level {
type Err = String;
fn from_str(word: &str) -> Result<Level, String> {
match word.trim() {
"classic" => Ok(Level::Classic),
"quantum-safe" => Ok(Level::QuantumSafe),
other => Err(format!("{other:?} is not a level: classic or quantum-safe")),
}
}
}
impl std::fmt::Display for Level {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.word())
}
}
impl Level {
pub const NEW: Level = Level::QuantumSafe;
pub const UNSTATED: Level = Level::Classic;
pub fn word(self) -> &'static str {
match self {
Level::Classic => "classic",
Level::QuantumSafe => "quantum-safe",
}
}
pub fn takes(self, kind: KeyKind) -> bool {
match self {
Level::Classic => kind == KeyKind::Age,
Level::QuantumSafe => kind == KeyKind::AgePq,
}
}
pub fn stanza_types(self) -> &'static [&'static str] {
match self {
Level::Classic => &["X25519"],
Level::QuantumSafe => &[PQ_STANZA],
}
}
}
const PQ_HRP: &str = "age1pq";
pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;
const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;
pub fn looks_like_pq_key(key: &str) -> bool {
const ALPHABET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
let key = key.trim();
key.len() == PQ_KEY_CHARS
&& key
.strip_prefix("age1pq1")
.is_some_and(|data| data.chars().all(|c| ALPHABET.contains(c)))
}
fn pq_key_ok(key: &str) -> bool {
#[cfg(feature = "pq-keys")]
{
pq_public_key_bytes(key).is_some()
}
#[cfg(not(feature = "pq-keys"))]
{
looks_like_pq_key(key)
}
}
#[cfg(feature = "pq-keys")]
pub fn pq_public_key_bytes(key: &str) -> Option<Vec<u8>> {
use bech32::FromBase32 as _;
let key = key.trim();
if !key.starts_with("age1pq1") || key.bytes().any(|b| b.is_ascii_uppercase()) {
return None;
}
let (hrp, data, variant) = bech32::decode(key).ok()?;
if hrp != PQ_HRP || variant != bech32::Variant::Bech32 {
return None;
}
let bytes = Vec::<u8>::from_base32(&data).ok()?;
(bytes.len() == PQ_PUBLIC_KEY_BYTES).then_some(bytes)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyRefusal {
SecurityKeyOnly(String),
Unsupported(String),
Malformed,
MalformedPq,
}
impl std::fmt::Display for KeyRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyRefusal::SecurityKeyOnly(t) => write!(
f,
"{t} is a security-key (YubiKey/FIDO) SSH key: it can only sign, so nothing \
can be encrypted to it. Add an ordinary ed25519 key (ssh-keygen -t ed25519) \
for encrypted repositories; keep the security key for login."
),
KeyRefusal::Unsupported(t) => write!(
f,
"{t} keys cannot be encryption recipients; use ssh-ed25519 (or ssh-rsa)"
),
KeyRefusal::Malformed => write!(f, "not an SSH or age public key"),
KeyRefusal::MalformedPq => write!(
f,
"not a well-formed age1pq1… key (age's hybrid ML-KEM-768 + X25519 recipient)"
),
}
}
}
impl std::error::Error for KeyRefusal {}
pub fn classify(key: &str) -> Result<KeyKind, KeyRefusal> {
let key = key.trim();
let ty = key.split_ascii_whitespace().next().unwrap_or("");
if ty.starts_with("age1pq1") {
return match (key.split_ascii_whitespace().count(), pq_key_ok(ty)) {
(1, true) => Ok(KeyKind::AgePq),
_ => Err(KeyRefusal::MalformedPq),
};
}
if ty.starts_with("age1") && ty.len() > 10 && key.split_ascii_whitespace().count() == 1 {
return Ok(KeyKind::Age);
}
let has_body = key.split_ascii_whitespace().nth(1).is_some();
match ty {
"ssh-ed25519" if has_body => Ok(KeyKind::SshEd25519),
"ssh-rsa" if has_body => Ok(KeyKind::SshRsa),
t if t.starts_with("sk-") && has_body => Err(KeyRefusal::SecurityKeyOnly(t.to_string())),
t if (t.starts_with("ecdsa-") || t.starts_with("ssh-dss")) && has_body => {
Err(KeyRefusal::Unsupported(t.to_string()))
}
_ => Err(KeyRefusal::Malformed),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberKey {
pub name: String,
pub key: String,
pub kind: KeyKind,
}
impl MemberKey {
pub fn key_id(&self) -> &str {
key_id(&self.key)
}
}
fn key_id(key: &str) -> &str {
let key = key.trim();
let mut it = key.match_indices(' ');
match (it.next(), it.next()) {
(Some(_), Some((second, _))) => &key[..second],
_ => key,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MembersError {
BadLine(usize),
BadName(String),
Key(usize, KeyRefusal),
Duplicate(String),
BadLevel(String),
SshKeyIsAMethod {
name: String,
},
WrongLevel {
name: String,
kind: KeyKind,
level: Level,
},
}
impl std::fmt::Display for MembersError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MembersError::BadLine(n) => write!(f, "members line {n} is not `<name> <public key>`"),
MembersError::BadName(n) => write!(
f,
"member name {n:?} may only use letters, digits and . _ @ -"
),
MembersError::Key(n, r) => write!(f, "members line {n}: {r}"),
MembersError::Duplicate(k) => write!(f, "the key {k} is listed twice"),
MembersError::BadLevel(l) => write!(
f,
"`level {l}` is not a level (classic or quantum-safe); a newer gunnar may know it"
),
MembersError::SshKeyIsAMethod { name } => write!(
f,
"{name}'s SSH key cannot be a member key: a repository is wrapped to {name}'s \
encryption key (age1…), which the SSH key unlocks. Add {name} with \
`gunnar crypt add-member {name} --from-server` (gunnar holds that key's public half)"
),
MembersError::WrongLevel { name, kind, level: Level::QuantumSafe } => write!(
f,
"{name}'s {} key cannot be a recipient: this repository is quantum-safe, and an \
X25519 key exchange is what a quantum computer breaks. {name} needs a \
quantum-safe encryption key (age1pq1…), which `gunnar crypt key new` makes. A \
person whose one key is classic is a member of classic repositories only \
(`gunnar crypt init --level classic`)",
kind.stanza_type()
),
MembersError::WrongLevel { name, level: Level::Classic, .. } => write!(
f,
"{name}'s age1pq1… encryption key is quantum-safe and this repository is classic; \
age never mixes the two in one key ring. `gunnar crypt level quantum-safe` makes \
this repository quantum-safe once every member has a quantum-safe key"
),
}
}
}
impl std::error::Error for MembersError {}
fn valid_name(n: &str) -> bool {
!n.is_empty()
&& n.len() <= 64
&& n.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'@' | b'-'))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Members {
level: Level,
entries: Vec<MemberKey>,
}
impl Default for Members {
fn default() -> Members {
Members::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpgradeRefusal {
pub missing: Vec<String>,
pub error: Option<MembersError>,
}
impl std::fmt::Display for UpgradeRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(e) = &self.error {
return write!(f, "{e}");
}
write!(
f,
"not quantum-safe yet: {} {} no quantum-safe encryption key (age1pq1…). A quantum-safe \
repository is wrapped only to such keys, and age never mixes them with classic ones, so \
EVERY member needs one: give it (`--key NAME=age1pq1…`), or remove the member first \
(`gunnar crypt remove-member NAME`). Nothing was changed",
self.missing.join(", "),
if self.missing.len() == 1 { "has" } else { "have" }
)
}
}
impl std::error::Error for UpgradeRefusal {}
impl Members {
pub fn new() -> Members {
Members::at(Level::NEW)
}
pub fn at(level: Level) -> Members {
Members {
level,
entries: Vec::new(),
}
}
pub fn to_quantum_safe(
&self,
keys: &std::collections::BTreeMap<String, String>,
) -> Result<Members, UpgradeRefusal> {
if self.level == Level::QuantumSafe {
return Ok(self.clone());
}
let names = self.names();
let missing: Vec<String> = names
.iter()
.filter(|n| {
!keys
.get(**n)
.is_some_and(|k| classify(k) == Ok(KeyKind::AgePq))
})
.map(|n| n.to_string())
.collect();
if !missing.is_empty() {
return Err(UpgradeRefusal {
missing,
error: None,
});
}
let mut up = Members::at(Level::QuantumSafe);
for n in names {
up.add(n, &keys[n]).map_err(|e| UpgradeRefusal {
missing: Vec::new(),
error: Some(e),
})?;
}
Ok(up)
}
pub fn parse(text: &str) -> Result<Members, MembersError> {
let mut m = Members::at(Level::UNSTATED);
for line in text.lines().map(str::trim) {
let mut words = line.split_ascii_whitespace();
if let (Some("level"), Some(word), None) = (words.next(), words.next(), words.next()) {
if !word.starts_with("ssh-") && !word.starts_with("age1") {
m.level = match word {
"classic" => Level::Classic,
"quantum-safe" => Level::QuantumSafe,
other => return Err(MembersError::BadLevel(other.to_string())),
};
}
}
}
for (i, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((name, key)) = line.split_once(char::is_whitespace) else {
return Err(MembersError::BadLine(i + 1));
};
if name == "level" && matches!(key.trim(), "classic" | "quantum-safe") {
continue;
}
m.add(name, key.trim()).map_err(|e| match e {
MembersError::Key(_, r) => MembersError::Key(i + 1, r),
other => other,
})?;
}
Ok(m)
}
pub fn level(&self) -> Level {
self.level
}
pub fn set_level(&mut self, level: Level) -> Result<(), MembersError> {
if let Some(e) = self.entries.iter().find(|e| !level.takes(e.kind)) {
return Err(MembersError::WrongLevel {
name: e.name.clone(),
kind: e.kind,
level,
});
}
self.level = level;
Ok(())
}
pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
self.entries.retain(keep);
}
pub fn to_text(&self) -> String {
let mut s = String::from(HEADER);
s.push('\n');
if self.level != Level::Classic {
s.push_str("level ");
s.push_str(self.level.word());
s.push('\n');
}
for e in &self.entries {
s.push_str(&e.name);
s.push(' ');
s.push_str(e.key.trim());
s.push('\n');
}
s
}
pub fn add(&mut self, name: &str, key: &str) -> Result<(), MembersError> {
if !valid_name(name) {
return Err(MembersError::BadName(name.to_string()));
}
let kind = classify(key).map_err(|r| match r {
KeyRefusal::SecurityKeyOnly(_) => MembersError::SshKeyIsAMethod {
name: name.to_string(),
},
r => MembersError::Key(0, r),
})?;
if matches!(kind, KeyKind::SshEd25519 | KeyKind::SshRsa) {
return Err(MembersError::SshKeyIsAMethod {
name: name.to_string(),
});
}
if !self.level.takes(kind) {
return Err(MembersError::WrongLevel {
name: name.to_string(),
kind,
level: self.level,
});
}
let id = key_id(key);
if self.entries.iter().any(|e| e.key_id() == id) {
return Err(MembersError::Duplicate(id.to_string()));
}
self.entries.push(MemberKey {
name: name.to_string(),
key: key.trim().to_string(),
kind,
});
Ok(())
}
pub fn remove(&mut self, name: &str) -> usize {
let before = self.entries.len();
self.entries.retain(|e| e.name != name);
before - self.entries.len()
}
pub fn entries(&self) -> &[MemberKey] {
&self.entries
}
pub fn names(&self) -> Vec<&str> {
let mut v: Vec<&str> = Vec::new();
for e in &self.entries {
if !v.contains(&e.name.as_str()) {
v.push(&e.name);
}
}
v
}
pub fn contains(&self, name: &str) -> bool {
self.entries.iter().any(|e| e.name == name)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}