server/lib/src/repo_sign.rs
//! ★ **The repository-metadata signing key** (lane HOL-SIGNED-APT-DNF, HOL-FIELD-TEST P4).
//!
//! The deb and rpm chain views ([`crate::chain_view`]) synthesize their indexes, so nobody
//! upstream signed them: until this module apt needed `[trusted=yes]` and dnf `gpgcheck=0`.
//! Now a Holger that holds a repository key signs what it serves:
//!
//! | Path (under a view) | Body |
//! |---|---|
//! | deb `dists/<suite>/InRelease` | `Release`, clear-signed (RFC 4880 §7) |
//! | deb `dists/<suite>/Release.gpg` | a detached armored signature over `Release` |
//! | rpm `repodata/repomd.xml.asc` | a detached armored signature over `repomd.xml` |
//!
//! and the public key is open at [`KEY_PATH`] (`/-/repo-key.asc`, no credential: a client
//! that must sign in to fetch the key it verifies with never verifies), which the console's
//! client setup names (`signed-by=` for apt, `repo_gpgcheck=1` + `gpgkey=` for dnf).
//!
//! **The key is the operator's, made on the box.** `holger repo-key create --config <ron>`
//! makes it ([`create`]) in the data root's key store, `<data root>/`[`DIR`]`/`[`SECRET_FILE`],
//! written 0600 and read through [`crate::keyfile::read_private`] like every other key Holger
//! holds (a Kubernetes secret volume mounted at `<data root>/repo-signing/` is the other shape
//! it takes). Holger never makes one on its own and no key lives in a repository: a box
//! without one serves its views unsigned, as before, and says so at boot. An operator may also
//! bring a key made elsewhere: an unprotected Ed25519 OpenPGP secret key in the same file
//! (`gpg --export-secret-keys --armor` of a key with no passphrase).
//!
//! **Why no OpenPGP crate signs here.** The only pure-Rust OpenPGP crate in the tree is rpgp
//! (`pgp =0.21.0`, MIT OR Apache-2.0), and it is about 93 crates: the default
//! `cargo install holger` is bounded (holger-recipe `cargo_install_holger.rs`) and the
//! `default + online` shape forbids OpenPGP outright (H2 #3, `every_shape_stays_slim.rs`),
//! so behind a feature it would leave exactly the builds people install unsigned. Signing
//! needs much less than a verifier: one v4 Ed25519 key, its user-ID self-signature, and
//! binary/text signature packets, over `ring` (already linked). This module writes those and
//! parses back only the key file it writes. Every shape it writes is verified by rpgp in the
//! tests, and by real apt (sqv/gpgv) and real dnf (librepo) in containers.
use std::path::{Path, PathBuf};
use base64::Engine as _;
use ring::signature::KeyPair as _;
/// The public key's door, open on every box.
pub const KEY_PATH: &str = "/-/repo-key.asc";
/// The key store's directory under the data root.
pub const DIR: &str = "repo-signing";
/// The secret key's file in [`DIR`].
pub const SECRET_FILE: &str = "secret.asc";
/// What a Holger with no repository key answers at [`KEY_PATH`].
pub const NO_KEY: &str = "this Holger holds no repository signing key, so its deb and rpm views \
serve unsigned metadata; the operator makes one on the box with `holger repo-key create --config <ron>`";
/// The user ID a key gets when the operator names none.
pub const DEFAULT_USER_ID: &str = "Holger repository signing key";
const OID_ED25519: [u8; 9] = [0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01];
/// EdDSA (legacy), the v4 Ed25519 algorithm every apt, gpgv, sqv and rpm verifies.
const ALG_EDDSA: u8 = 22;
/// SHA-512.
const HASH_SHA512: u8 = 10;
/// Where the repository key of a box with `data_root` lives.
pub fn secret_path(data_root: &Path) -> PathBuf {
data_root.join(DIR).join(SECRET_FILE)
}
/// **A repository signing key**: a v4 Ed25519 OpenPGP primary key with one user ID.
pub struct RepoKey {
pair: ring::signature::Ed25519KeyPair,
seed: [u8; 32],
created: u32,
user_id: String,
fingerprint: [u8; 20],
public_armor: String,
}
impl std::fmt::Debug for RepoKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RepoKey")
.field("fingerprint", &self.fingerprint_hex())
.field("user_id", &self.user_id)
.finish()
}
}
impl RepoKey {
/// A new key from the system's randomness, created at `created` (unix seconds).
pub fn generate(user_id: &str, created: u32) -> anyhow::Result<Self> {
let mut seed = [0u8; 32];
ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut seed)
.map_err(|_| anyhow::anyhow!("the system's randomness is unavailable"))?;
Self::from_seed(seed, created, user_id)
}
fn from_seed(seed: [u8; 32], created: u32, user_id: &str) -> anyhow::Result<Self> {
let user_id = user_id.trim();
if user_id.is_empty() || user_id.contains(['\n', '\r']) {
anyhow::bail!("a repository key's user ID is one non-empty line");
}
let pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&seed)
.map_err(|e| anyhow::anyhow!("the repository key is not an Ed25519 key: {e}"))?;
let mut k = Self {
pair,
seed,
created,
user_id: user_id.to_string(),
fingerprint: [0; 20],
public_armor: String::new(),
};
let body = k.public_body();
let mut fp = vec![0x99];
fp.extend_from_slice(&(body.len() as u16).to_be_bytes());
fp.extend_from_slice(&body);
let d = ring::digest::digest(&ring::digest::SHA1_FOR_LEGACY_USE_ONLY, &fp);
k.fingerprint.copy_from_slice(d.as_ref());
let mut block = packet(6, &body);
block.extend(k.user_id_and_self_sig());
k.public_armor = armor("PGP PUBLIC KEY BLOCK", &block);
Ok(k)
}
/// The primary key's fingerprint, upper-case hex (as apt and gpg print it).
pub fn fingerprint_hex(&self) -> String {
self.fingerprint
.iter()
.map(|b| format!("{b:02X}"))
.collect()
}
/// The user ID.
pub fn user_id(&self) -> &str {
&self.user_id
}
/// The public key, armored (`-----BEGIN PGP PUBLIC KEY BLOCK-----`): what
/// [`KEY_PATH`] serves, apt's `signed-by=` file and dnf's `gpgkey=`.
pub fn public_armor(&self) -> &str {
&self.public_armor
}
fn key_id(&self) -> [u8; 8] {
let mut id = [0u8; 8];
id.copy_from_slice(&self.fingerprint[12..]);
id
}
/// The v4 public-key packet body.
fn public_body(&self) -> Vec<u8> {
let mut b = vec![4];
b.extend_from_slice(&self.created.to_be_bytes());
b.push(ALG_EDDSA);
b.push(OID_ED25519.len() as u8);
b.extend_from_slice(&OID_ED25519);
let mut point = vec![0x40];
point.extend_from_slice(self.pair.public_key().as_ref());
b.extend(mpi(&point));
b
}
/// The user-ID packet and its positive self-certification (certify + sign).
fn user_id_and_self_sig(&self) -> Vec<u8> {
let uid = self.user_id.as_bytes();
let body = self.public_body();
let mut data = vec![0x99];
data.extend_from_slice(&(body.len() as u16).to_be_bytes());
data.extend_from_slice(&body);
data.push(0xB4);
data.extend_from_slice(&(uid.len() as u32).to_be_bytes());
data.extend_from_slice(uid);
let extra = [
subpacket(27, &[0x03]), // key flags: certify, sign
subpacket(21, &[HASH_SHA512, 8]), // preferred hashes: SHA-512, SHA-256
subpacket(30, &[0x01]), // features: MDC
]
.concat();
let mut out = packet(13, uid);
// The key's own creation time: the self-signature, and so the public block, are
// the same bytes every time the key is read.
out.extend(packet(
2,
&self.signature(0x13, &data, self.created, &extra),
));
out
}
/// A v4 signature packet body of `sig_type` over `data`, made at `at`.
fn signature(&self, sig_type: u8, data: &[u8], at: u32, extra_hashed: &[u8]) -> Vec<u8> {
let mut issuer_fpr = vec![4];
issuer_fpr.extend_from_slice(&self.fingerprint);
let hashed = [
subpacket(2, &at.to_be_bytes()),
subpacket(33, &issuer_fpr),
extra_hashed.to_vec(),
]
.concat();
let mut head = vec![4, sig_type, ALG_EDDSA, HASH_SHA512];
head.extend_from_slice(&(hashed.len() as u16).to_be_bytes());
head.extend_from_slice(&hashed);
let mut ctx = ring::digest::Context::new(&ring::digest::SHA512);
ctx.update(data);
ctx.update(&head);
ctx.update(&[4, 0xFF]);
ctx.update(&(head.len() as u32).to_be_bytes());
let digest = ctx.finish();
let sig = self.pair.sign(digest.as_ref());
let sig = sig.as_ref();
let unhashed = subpacket(16, &self.key_id());
let mut body = head;
body.extend_from_slice(&(unhashed.len() as u16).to_be_bytes());
body.extend_from_slice(&unhashed);
body.extend_from_slice(&digest.as_ref()[..2]);
body.extend(mpi(&sig[..32]));
body.extend(mpi(&sig[32..]));
body
}
/// **A detached, armored signature over `data`** (a binary-document signature):
/// apt's `Release.gpg`, rpm-md's `repomd.xml.asc`.
pub fn sign_detached(&self, data: &[u8], at: u32) -> String {
armor(
"PGP SIGNATURE",
&packet(2, &self.signature(0x00, data, at, &[])),
)
}
/// **`text`, clear-signed** (RFC 4880 §7): apt's `InRelease`. The signed text is the
/// canonical one (lines joined by CRLF, trailing blanks dropped, the line end before the
/// signature not part of it), dash-escaped where a line starts with `-`.
pub fn sign_cleartext(&self, text: &str, at: u32) -> String {
let body = text.strip_suffix('\n').unwrap_or(text);
let lines: Vec<&str> = body
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.collect();
let canonical = lines
.iter()
.map(|l| l.trim_end_matches([' ', '\t']))
.collect::<Vec<_>>()
.join("\r\n");
let sig = self.signature(0x01, canonical.as_bytes(), at, &[]);
let mut out = String::from("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n");
for l in &lines {
if l.starts_with('-') {
out.push_str("- ");
}
out.push_str(l);
out.push('\n');
}
out.push_str(&armor("PGP SIGNATURE", &packet(2, &sig)));
out
}
/// The secret key, armored (`-----BEGIN PGP PRIVATE KEY BLOCK-----`, unprotected): the
/// file [`create`] writes 0600. `gpg --import` takes it.
fn secret_armor(&self) -> String {
let mut body = self.public_body();
body.push(0); // no S2K: the file's mode protects it, as every key file Holger holds
let secret = mpi(&self.seed);
let sum = secret
.iter()
.fold(0u16, |a, b| a.wrapping_add(u16::from(*b)));
body.extend_from_slice(&secret);
body.extend_from_slice(&sum.to_be_bytes());
let mut block = packet(5, &body);
block.extend(self.user_id_and_self_sig());
armor("PGP PRIVATE KEY BLOCK", &block)
}
/// **Read a secret key file**: an unprotected v4 Ed25519 OpenPGP secret key (the one
/// [`create`] writes, or one exported from gpg with no passphrase), armored or binary.
pub fn from_secret(bytes: &[u8]) -> anyhow::Result<Self> {
let bin = if bytes.trim_ascii_start().starts_with(b"-----BEGIN") {
dearmor(bytes)?
} else {
bytes.to_vec()
};
let mut rest = bin.as_slice();
let mut secret: Option<(u32, [u8; 32], Vec<u8>)> = None;
let mut user_id: Option<String> = None;
while !rest.is_empty() {
let (tag, body, next) = read_packet(rest)?;
rest = next;
match tag {
5 if secret.is_none() => secret = Some(parse_secret(body)?),
13 if user_id.is_none() && secret.is_some() => {
user_id = Some(String::from_utf8_lossy(body).into_owned())
}
_ => {}
}
}
let Some((created, seed, public)) = secret else {
anyhow::bail!("the file holds no OpenPGP secret key packet");
};
let key = Self::from_seed(seed, created, user_id.as_deref().unwrap_or(DEFAULT_USER_ID))?;
if key.pair.public_key().as_ref() != public.as_slice() {
anyhow::bail!("the secret key's public half is not the key's own");
}
Ok(key)
}
}
/// `(created, seed, public key)` of a v4 Ed25519 secret-key packet with no S2K.
fn parse_secret(b: &[u8]) -> anyhow::Result<(u32, [u8; 32], Vec<u8>)> {
let bad = |why: &str| anyhow::anyhow!("not a repository key Holger signs with: {why}");
if b.len() < 6 || b[0] != 4 {
return Err(bad("only a v4 key is taken"));
}
let created = u32::from_be_bytes([b[1], b[2], b[3], b[4]]);
if b[5] != ALG_EDDSA {
return Err(bad("only an Ed25519 (EdDSA) key is taken"));
}
let mut r = &b[6..];
let oid_len = *r.first().ok_or_else(|| bad("truncated"))? as usize;
if r.len() < 1 + oid_len || r[1..1 + oid_len] != OID_ED25519 {
return Err(bad("the curve is not Ed25519"));
}
r = &r[1 + oid_len..];
let (point, next) = read_mpi(r)?;
r = next;
if point.len() != 33 || point[0] != 0x40 {
return Err(bad("the public point is not a native Ed25519 point"));
}
match r.first() {
Some(0) => {}
Some(_) => {
return Err(bad(
"the secret key is passphrase-protected; export it without a passphrase \
(the file is 0600 and Holger reads it unattended)",
))
}
None => return Err(bad("truncated")),
}
r = &r[1..];
let start = r;
let (s, next) = read_mpi(r)?;
let used = start.len() - next.len();
let sum = start[..used]
.iter()
.fold(0u16, |a, b| a.wrapping_add(u16::from(*b)));
if next.len() < 2 || u16::from_be_bytes([next[0], next[1]]) != sum {
return Err(bad("the secret key's checksum does not match"));
}
if s.len() > 32 {
return Err(bad("the secret scalar is longer than an Ed25519 seed"));
}
let mut seed = [0u8; 32];
seed[32 - s.len()..].copy_from_slice(&s);
Ok((created, seed, point[1..].to_vec()))
}
/// **Make the box's repository key** in `data_root`'s key store (`holger repo-key create`):
/// refused when one is there (a key is replaced by the operator removing it, never by a
/// second create). The file is 0600 from its first byte and published without replacing
/// anything ([`write_new_private`]).
pub fn create(data_root: &Path, user_id: &str) -> anyhow::Result<RepoKey> {
let file = secret_path(data_root);
if file.exists() {
anyhow::bail!("{}", already_there(&file));
}
let key = RepoKey::generate(user_id, now())?;
let dir = data_root.join(DIR);
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
}
match write_new_private(&file, key.secret_armor().as_bytes()) {
Ok(()) => Ok(key),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
anyhow::bail!("{}", already_there(&file))
}
Err(e) => Err(anyhow::anyhow!("{}: {e}", file.display())),
}
}
fn already_there(file: &Path) -> String {
format!(
"{} already holds this box's repository key; it is never replaced by a create \
(a client pinned its public half). Remove the file first to make a new one",
file.display()
)
}
/// **Write a new private file** (HOL-SIGN-2): `bytes` into `file`, which must not exist.
///
/// * The temporary beside it (`<file>.tmp`) is removed first, never reused: one left by a
/// create that died, or put there by anyone, keeps its own mode (a reused 0644 file stays
/// 0644 after a truncate) and may be a symlink a write would follow.
/// * It is made with `O_CREAT|O_EXCL` (which follows no symlink) and mode 0600, then set
/// 0600 whatever the umask, written and synced.
/// * It is published by a hard link, which fails with `AlreadyExists` when `file` appeared
/// meanwhile (a second create racing this one), where a rename would replace that key.
pub(crate) fn write_new_private(file: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut tmp = file.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
match std::fs::remove_file(&tmp) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
{
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
f.write_all(bytes)?;
f.sync_all()?;
}
let linked = std::fs::hard_link(&tmp, file);
let _ = std::fs::remove_file(&tmp);
linked?;
if let Some(dir) = file.parent() {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
Ok(())
}
/// **The box's repository key**, when the operator made one: `None` when the key store
/// holds none; an error when it holds one that cannot be used (others can read it, it does
/// not parse), which refuses the boot rather than serving unsigned under a key the operator
/// thinks is in use.
pub fn load(data_root: &Path) -> anyhow::Result<Option<RepoKey>> {
let file = secret_path(data_root);
match crate::keyfile::read_private(&file, "Holger's repository signing key") {
Ok(b) => RepoKey::from_secret(&b)
.map(Some)
.map_err(|e| anyhow::anyhow!("{}: {e}", file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::anyhow!("{}: {e}", file.display())),
}
}
/// Now, in unix seconds (a signature's creation time).
pub fn now() -> u32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as u32)
.unwrap_or(0)
}
// ── OpenPGP framing ─────────────────────────────────────────────────────────────────
/// A multiprecision integer: the bit count, then the bytes without leading zeros.
fn mpi(bytes: &[u8]) -> Vec<u8> {
let first = bytes.iter().position(|b| *b != 0).unwrap_or(bytes.len());
let v = &bytes[first..];
let bits = match v.first() {
Some(b) => (v.len() - 1) * 8 + (8 - b.leading_zeros() as usize),
None => 0,
};
let mut out = (bits as u16).to_be_bytes().to_vec();
out.extend_from_slice(v);
out
}
fn read_mpi(b: &[u8]) -> anyhow::Result<(Vec<u8>, &[u8])> {
if b.len() < 2 {
anyhow::bail!("a truncated OpenPGP number");
}
let bits = u16::from_be_bytes([b[0], b[1]]) as usize;
let n = bits.div_ceil(8);
if b.len() < 2 + n {
anyhow::bail!("a truncated OpenPGP number");
}
Ok((b[2..2 + n].to_vec(), &b[2 + n..]))
}
/// A new-format packet of `tag`.
fn packet(tag: u8, body: &[u8]) -> Vec<u8> {
let mut out = vec![0xC0 | tag];
let n = body.len();
if n < 192 {
out.push(n as u8);
} else if n < 8384 {
let l = n - 192;
out.push(((l >> 8) + 192) as u8);
out.push((l & 0xFF) as u8);
} else {
out.push(0xFF);
out.extend_from_slice(&(n as u32).to_be_bytes());
}
out.extend_from_slice(body);
out
}
/// One packet of `b`, old or new format (no partial lengths: a key file has none):
/// `(tag, body, the rest)`.
fn read_packet(b: &[u8]) -> anyhow::Result<(u8, &[u8], &[u8])> {
let trunc = || anyhow::anyhow!("a truncated OpenPGP packet");
let h = *b.first().ok_or_else(trunc)?;
if h & 0x80 == 0 {
anyhow::bail!("not an OpenPGP packet");
}
let (tag, len, at) = if h & 0x40 != 0 {
let tag = h & 0x3F;
let o = *b.get(1).ok_or_else(trunc)? as usize;
match o {
0..=191 => (tag, o, 2),
192..=223 => {
let o2 = *b.get(2).ok_or_else(trunc)? as usize;
(tag, ((o - 192) << 8) + o2 + 192, 3)
}
255 => {
let l = b.get(2..6).ok_or_else(trunc)?;
(
tag,
u32::from_be_bytes([l[0], l[1], l[2], l[3]]) as usize,
6,
)
}
_ => anyhow::bail!("a partial-length packet in a key file"),
}
} else {
let tag = (h >> 2) & 0x0F;
match h & 0x03 {
0 => (tag, *b.get(1).ok_or_else(trunc)? as usize, 2),
1 => {
let l = b.get(1..3).ok_or_else(trunc)?;
(tag, u16::from_be_bytes([l[0], l[1]]) as usize, 3)
}
2 => {
let l = b.get(1..5).ok_or_else(trunc)?;
(
tag,
u32::from_be_bytes([l[0], l[1], l[2], l[3]]) as usize,
5,
)
}
_ => anyhow::bail!("an indeterminate-length packet in a key file"),
}
};
let end = usize::checked_add(at, len).ok_or_else(trunc)?;
let body = b.get(at..end).ok_or_else(trunc)?;
Ok((tag, body, &b[end..]))
}
/// A signature subpacket (`len`, `type`, data).
fn subpacket(ty: u8, data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() + 2);
let n = data.len() + 1;
if n < 192 {
out.push(n as u8);
} else {
let l = n - 192;
out.push(((l >> 8) + 192) as u8);
out.push((l & 0xFF) as u8);
}
out.push(ty);
out.extend_from_slice(data);
out
}
/// The OpenPGP CRC-24 of `data`.
fn crc24(data: &[u8]) -> u32 {
let mut crc: u32 = 0x00B7_04CE;
for b in data {
crc ^= u32::from(*b) << 16;
for _ in 0..8 {
crc <<= 1;
if crc & 0x0100_0000 != 0 {
crc ^= 0x0186_4CFB;
}
}
}
crc & 0x00FF_FFFF
}
/// ASCII armor of `data` under `label`.
fn armor(label: &str, data: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD;
let body = b64.encode(data);
let mut out = format!("-----BEGIN {label}-----\n\n");
for line in body.as_bytes().chunks(64) {
out.push_str(std::str::from_utf8(line).unwrap_or_default());
out.push('\n');
}
let crc = crc24(data).to_be_bytes();
out.push('=');
out.push_str(&b64.encode(&crc[1..]));
out.push('\n');
out.push_str(&format!("-----END {label}-----\n"));
out
}
/// The bytes of the first armored block of `text` (its CRC checked when present).
fn dearmor(text: &[u8]) -> anyhow::Result<Vec<u8>> {
let text = std::str::from_utf8(text).map_err(|_| anyhow::anyhow!("the armor is not text"))?;
let mut lines = text.lines().map(str::trim_end);
lines
.by_ref()
.find(|l| l.starts_with("-----BEGIN "))
.ok_or_else(|| anyhow::anyhow!("no armor header"))?;
// Armor headers (`Comment: …`) up to the blank line.
for l in lines.by_ref() {
if l.is_empty() {
break;
}
}
let mut b64 = String::new();
let mut crc: Option<&str> = None;
for l in lines {
if l.starts_with("-----END ") {
break;
}
if let Some(c) = l.strip_prefix('=') {
crc = Some(c);
continue;
}
b64.push_str(l);
}
let data = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| anyhow::anyhow!("the armor's base64 does not decode: {e}"))?;
if let Some(c) = crc {
let want = base64::engine::general_purpose::STANDARD
.decode(c)
.map_err(|e| anyhow::anyhow!("the armor's checksum does not decode: {e}"))?;
if want.as_slice() != &crc24(&data).to_be_bytes()[1..] {
anyhow::bail!("the armor's checksum does not match");
}
}
Ok(data)
}
#[cfg(test)]
mod tests {
use super::*;
use pgp::composed::{
CleartextSignedMessage, Deserializable, DetachedSignature, SignedPublicKey,
};
use pgp::types::KeyDetails as _;
fn key() -> RepoKey {
RepoKey::generate("Holger test repository key", 1_760_000_000).unwrap()
}
fn public(k: &RepoKey) -> SignedPublicKey {
let (p, _) = SignedPublicKey::from_armor_single(k.public_armor().as_bytes()).unwrap();
p.verify_bindings().expect("the self-signature verifies");
p
}
/// ★ The public block is a key rpgp takes, its fingerprint is the one Holger prints, and
/// it is the same bytes each time the key is read back.
#[test]
fn the_public_key_is_an_openpgp_key_with_a_verified_self_signature() {
let k = key();
let p = public(&k);
assert_eq!(format!("{:X}", p.fingerprint()), k.fingerprint_hex());
assert_eq!(p.details.users[0].id.id(), k.user_id().as_bytes());
let again = RepoKey::from_secret(k.secret_armor().as_bytes()).unwrap();
assert_eq!(again.public_armor(), k.public_armor());
assert_eq!(again.fingerprint_hex(), k.fingerprint_hex());
}
/// ★ `Release.gpg` / `repomd.xml.asc`: a detached signature over the bytes verifies, and
/// over a changed byte it does not.
#[test]
fn a_detached_signature_verifies_and_a_tampered_body_does_not() {
let k = key();
let p = public(&k);
let body = b"<repomd>\n <revision>1</revision>\n</repomd>\n";
let asc = k.sign_detached(body, 1_760_000_100);
let (sig, _) = DetachedSignature::from_armor_single(asc.as_bytes()).unwrap();
sig.verify(&p, body).expect("the signature verifies");
let mut tampered = body.to_vec();
tampered[22] = b'2';
assert!(
sig.verify(&p, &tampered).is_err(),
"a changed body is refused"
);
}
/// ★ `InRelease`: the clear-signed Release verifies, carries the Release text, and a
/// changed line is refused; a line starting with `-` is dash-escaped.
#[test]
fn a_cleartext_signature_verifies_and_a_tampered_text_does_not() {
let k = key();
let p = public(&k);
let release = "Origin: Holger\nSuite: stable\nSHA256:\n abc 12 main/binary-amd64/Packages\n-dash: line\n";
let signed = k.sign_cleartext(release, 1_760_000_200);
assert!(signed.contains("\n- -dash: line\n"), "{signed}");
let (msg, _) = CleartextSignedMessage::from_string(&signed).unwrap();
msg.verify(&p).expect("the clear-signed text verifies");
assert_eq!(
msg.signed_text(),
release.trim_end_matches('\n').replace('\n', "\r\n")
);
let tampered = signed.replace("Origin: Holger", "Origin: Hxlger");
let (bad, _) = CleartextSignedMessage::from_string(&tampered).unwrap();
assert!(bad.verify(&p).is_err(), "a changed line is refused");
}
/// The key store: made 0600, refused a second time, read back the same; a group-readable
/// one refuses the load by name; none is `None`.
#[test]
fn the_key_store_makes_a_private_file_once_and_reads_it_back() {
use std::os::unix::fs::PermissionsExt as _;
let d = tempfile::tempdir().unwrap();
assert!(load(d.path()).unwrap().is_none());
let k = create(d.path(), DEFAULT_USER_ID).unwrap();
let mode = std::fs::metadata(secret_path(d.path()))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
let err = create(d.path(), DEFAULT_USER_ID).unwrap_err().to_string();
assert!(err.contains("never replaced"), "{err}");
let back = load(d.path()).unwrap().unwrap();
assert_eq!(back.fingerprint_hex(), k.fingerprint_hex());
std::fs::set_permissions(
secret_path(d.path()),
std::fs::Permissions::from_mode(0o640),
)
.unwrap();
let err = load(d.path()).unwrap_err().to_string();
assert!(err.contains("holger-key-1"), "{err}");
}
/// ★ HOL-SIGN-2 review: a temporary left in the key store (a create that died, or a
/// file anyone put there) never carries the key out: a 0644 one is not reused (the key
/// is 0600), and a symlink is not written through.
#[test]
fn a_left_over_temporary_never_carries_the_key_out_of_the_store() {
use std::os::unix::fs::PermissionsExt as _;
let d = tempfile::tempdir().unwrap();
let dir = d.path().join(DIR);
std::fs::create_dir_all(&dir).unwrap();
let tmp = dir.join(format!("{SECRET_FILE}.tmp"));
std::fs::write(&tmp, b"stale").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
create(d.path(), DEFAULT_USER_ID).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&secret_path(d.path())), 0o600, "the key is 0600");
assert!(load(d.path()).unwrap().is_some());
assert!(!tmp.exists(), "no temporary is left");
std::fs::remove_file(secret_path(d.path())).unwrap();
let outside = d.path().join("outside.txt");
std::fs::write(&outside, b"untouched").unwrap();
std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o644)).unwrap();
std::os::unix::fs::symlink(&outside, &tmp).unwrap();
create(d.path(), DEFAULT_USER_ID).unwrap();
assert_eq!(
std::fs::read(&outside).unwrap(),
b"untouched",
"not written through"
);
let meta = std::fs::symlink_metadata(secret_path(d.path())).unwrap();
assert!(meta.file_type().is_file(), "the key is a file, not a link");
assert_eq!(mode(&secret_path(d.path())), 0o600);
}
/// ★ HOL-SIGN-2 review: a key that appears while a create runs is never replaced.
#[test]
fn a_new_private_file_never_replaces_one_that_is_there() {
let d = tempfile::tempdir().unwrap();
let file = d.path().join("secret.asc");
std::fs::write(&file, b"the first key").unwrap();
let err = write_new_private(&file, b"a second key").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists, "{err}");
assert_eq!(std::fs::read(&file).unwrap(), b"the first key");
assert!(!d.path().join("secret.asc.tmp").exists());
}
/// A packet length past the end of the file is a truncated packet, never an overflow.
#[test]
fn a_packet_longer_than_the_file_is_truncated() {
let huge = [0xC5, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 1, 2, 3];
let err = read_packet(&huge).unwrap_err().to_string();
assert!(err.contains("truncated"), "{err}");
let old = [0x96, 0xFF, 0xFF, 0xFF, 0xFF, 1];
assert!(read_packet(&old).is_err());
}
/// A passphrase-protected or a non-Ed25519 key is refused by name.
#[test]
fn a_key_holger_cannot_sign_with_is_refused() {
let k = key();
let mut body = k.public_body();
body.push(254);
let err = RepoKey::from_secret(&packet(5, &body))
.unwrap_err()
.to_string();
assert!(err.contains("passphrase"), "{err}");
let mut rsa = k.public_body();
rsa[5] = 1;
let err = RepoKey::from_secret(&packet(5, &rsa))
.unwrap_err()
.to_string();
assert!(err.contains("Ed25519"), "{err}");
}
#[test]
fn crc24_is_the_openpgp_one() {
// RFC 4880 §6.1: the CRC of nothing is the init value.
assert_eq!(crc24(b""), 0x00B7_04CE);
assert_eq!(dearmor(armor("X", b"hello").as_bytes()).unwrap(), b"hello");
}
}