use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
use base64::Engine as _;
use sha2::{Digest as _, Sha256};
use crate::envelope;
use crate::members::{KeyKind, Level, Members};
use crate::paths;
pub const AGE_BINARY_PREFIX: &[u8] = b"age-encryption.org/v1\n";
pub const AGE_ARMOR_PREFIX: &[u8] = b"-----BEGIN AGE ENCRYPTED FILE-----";
pub fn is_age_file(data: &[u8]) -> bool {
let trimmed = trim_leading_ws(data);
data.starts_with(AGE_BINARY_PREFIX) || trimmed.starts_with(AGE_ARMOR_PREFIX)
}
fn trim_leading_ws(mut d: &[u8]) -> &[u8] {
while let [first, rest @ ..] = d {
if first.is_ascii_whitespace() {
d = rest;
} else {
break;
}
}
d
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
Plaintext {
path: String,
},
KeysNotAge,
Symlink {
path: String,
},
ClassicalInQuantumSafe {
stanza: String,
},
RingMismatch(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Plaintext { path } => write!(
f,
"{path} is plaintext, and this repository is encrypted: the server stores \
ciphertext only. Run `gunnar crypt unlock` in this clone, then \
`git add --renormalize .` and commit again."
),
Refusal::KeysNotAge => write!(
f,
"{} must be an age file (the key ring wrapped to the members); \
write it with `gunnar crypt`",
paths::KEYS
),
Refusal::Symlink { path } => write!(
f,
"{path} is a symbolic link: git stores a link's target unencrypted, so \
encrypted repositories refuse links"
),
Refusal::ClassicalInQuantumSafe { stanza } => write!(
f,
"{} carries a {stanza} stanza, and this repository is quantum-safe \
(`level quantum-safe` in {}): its key ring is wrapped only to age1pq1… \
(ML-KEM-768 + X25519) keys, because one classical wrap beside them would be \
the weak link. Wrap it to the members' quantum-safe encryption keys (age1pq1…) only",
paths::KEYS,
paths::MEMBERS
),
Refusal::RingMismatch(why) => write!(
f,
"{} does not match {}: {why}. Write both with `gunnar crypt`",
paths::KEYS,
paths::MEMBERS
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shape {
pub envelope: bool,
pub age: bool,
}
impl Shape {
pub fn of(data: &[u8]) -> Shape {
Shape {
envelope: envelope::is_envelope(data),
age: is_age_file(data),
}
}
}
pub fn check_blob(path: &str, is_symlink: bool, data: &[u8]) -> Result<(), Refusal> {
check(path, is_symlink, Shape::of(data))
}
pub fn check(path: &str, is_symlink: bool, shape: Shape) -> Result<(), Refusal> {
if is_symlink {
return Err(Refusal::Symlink {
path: path.to_string(),
});
}
let attributes =
path == paths::GITATTRIBUTES || path.ends_with(&format!("/{}", paths::GITATTRIBUTES));
match path {
_ if attributes => Ok(()),
paths::MEMBERS => Ok(()),
paths::KEYS => {
if shape.age {
Ok(())
} else {
Err(Refusal::KeysNotAge)
}
}
_ if shape.envelope => Ok(()),
_ => Err(Refusal::Plaintext {
path: path.to_string(),
}),
}
}
const AGE_ARMOR_END: &str = "-----END AGE ENCRYPTED FILE-----";
pub const MAX_SCHEME_FILE: usize = 4 << 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StanzaHead {
pub tag: String,
pub args: Vec<String>,
pub body_len: usize,
}
pub fn age_stanzas(data: &[u8]) -> Option<Vec<StanzaHead>> {
if data.len() > MAX_SCHEME_FILE {
return None;
}
let binary;
let data = if trim_leading_ws(data).starts_with(AGE_ARMOR_PREFIX) {
let text = std::str::from_utf8(data).ok()?;
let mut b64 = String::new();
let mut inside = false;
for line in text.lines().map(str::trim) {
if line.as_bytes() == AGE_ARMOR_PREFIX {
inside = true;
} else if line == AGE_ARMOR_END {
break;
} else if inside {
b64.push_str(line);
}
}
binary = STANDARD.decode(b64.as_bytes()).ok()?;
&binary[..]
} else {
data
};
let mut lines = data.split(|b| *b == b'\n');
if lines.next()? != b"age-encryption.org/v1" {
return None;
}
let mut stanzas = Vec::new();
let mut line = lines.next()?;
loop {
if line.starts_with(b"--- ") {
return Some(stanzas);
}
let head = std::str::from_utf8(line.strip_prefix(b"-> ")?).ok()?;
let mut words = head.split(' ');
let tag = words.next().filter(|t| !t.is_empty())?.to_string();
let args: Vec<String> = words.map(str::to_string).collect();
if args.iter().any(String::is_empty) {
return None;
}
let mut body = Vec::new();
loop {
let l = lines.next()?;
body.extend_from_slice(l);
if l.len() < 64 {
break;
}
if l.len() > 64 {
return None;
}
}
let body_len = STANDARD_NO_PAD.decode(&body).ok()?.len();
stanzas.push(StanzaHead {
tag,
args,
body_len,
});
line = lines.next()?;
}
}
pub fn ssh_stanza_tag(public_key: &str) -> Option<String> {
let blob = STANDARD
.decode(public_key.split_ascii_whitespace().nth(1)?)
.ok()?;
Some(STANDARD_NO_PAD.encode(&Sha256::digest(&blob)[..4]))
}
fn is_grease(s: &StanzaHead) -> bool {
s.tag.ends_with("-grease")
&& s.tag.len() <= 8 + "-grease".len()
&& s.args.len() <= 4
&& s.args.iter().all(|a| a.len() <= 8)
&& s.body_len < 100
}
fn well_formed(s: &StanzaHead) -> bool {
let len = |i: usize| s.args.get(i).map_or(0, String::len);
match s.tag.as_str() {
"ssh-ed25519" => len(0) == 6 && len(1) == 43 && s.body_len == 32,
"ssh-rsa" => len(0) == 6 && (128..=2048).contains(&s.body_len),
"X25519" => len(0) == 43 && s.body_len == 32,
crate::members::PQ_STANZA => len(0) == 1494 && s.body_len == 32,
_ => false,
}
}
pub fn check_ring(members: &str, keys: &[u8]) -> Result<(), Refusal> {
for (path, len) in [(paths::MEMBERS, members.len()), (paths::KEYS, keys.len())] {
if len > MAX_SCHEME_FILE {
return Err(Refusal::RingMismatch(format!(
"{path} is larger than {} MiB, which no key ring of gunnar's needs",
MAX_SCHEME_FILE >> 20
)));
}
}
let members = Members::parse(members)
.map_err(|e| Refusal::RingMismatch(format!("{} is not readable ({e})", paths::MEMBERS)))?;
let stanzas = age_stanzas(keys).ok_or(Refusal::KeysNotAge)?;
let level = members.level();
let mut grease = 0;
let mut expected: std::collections::HashMap<(&'static str, Option<String>), usize> =
std::collections::HashMap::new();
for m in members.entries() {
let tag = match m.kind {
KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
KeyKind::Age | KeyKind::AgePq => None,
};
*expected.entry((m.kind.stanza_type(), tag)).or_default() += 1;
}
for s in &stanzas {
if is_grease(s) && grease == 0 {
grease += 1;
continue;
}
if level == Level::QuantumSafe && s.tag != crate::members::PQ_STANZA {
return Err(Refusal::ClassicalInQuantumSafe {
stanza: s.tag.clone(),
});
}
if !level.stanza_types().contains(&s.tag.as_str()) {
return Err(Refusal::RingMismatch(format!(
"it carries a {} stanza, which no member key of a {} repository makes",
s.tag,
level.word()
)));
}
let arity = match s.tag.as_str() {
"ssh-ed25519" => 2,
_ => 1,
};
let tag = match s.tag.as_str() {
"ssh-ed25519" | "ssh-rsa" => s.args.first().cloned(),
_ => None,
};
if s.args.len() != arity || !well_formed(s) {
return Err(Refusal::RingMismatch(format!(
"a {} stanza that is not the shape age writes ({} argument(s), a {}-byte body)",
s.tag,
s.args.len(),
s.body_len
)));
}
let left = level
.stanza_types()
.iter()
.find(|t| **t == s.tag)
.and_then(|t| expected.get_mut(&(*t, tag.clone())))
.filter(|n| **n > 0);
let Some(left) = left else {
return Err(Refusal::RingMismatch(match tag {
Some(tag) => format!(
"it is wrapped to an {} key (tag {tag}) that no member line lists",
s.tag
),
None => format!(
"it carries more {} stanzas than the members file lists {} keys",
s.tag, s.tag
),
}));
};
*left -= 1;
}
let missing: usize = expected.values().sum();
if missing > 0 {
let first = members
.entries()
.iter()
.find(|m| {
let tag = match m.kind {
KeyKind::SshEd25519 | KeyKind::SshRsa => ssh_stanza_tag(&m.key),
_ => None,
};
expected
.get(&(m.kind.stanza_type(), tag))
.is_some_and(|n| *n > 0)
})
.map(|m| format!("{}'s {} key", m.name, m.kind.stanza_type()))
.unwrap_or_default();
return Err(Refusal::RingMismatch(format!(
"{missing} member key(s) have no stanza (the first: {first})"
)));
}
Ok(())
}