// Rustler NIF for the apt repository: the things apt needs that OTP
// cannot do.
//
// There are two.
//
// 1. OpenPGP signatures on `Release`. `:public_key` speaks PKCS and
// X.509; OpenPGP is a different packet format with its own key
// material, and nothing in the Erlang distribution implements it.
//
// 2. xz and zstd. A .deb's control member is `control.tar.xz` on
// anything since squeeze and `control.tar.zst` on Ubuntu, and
// `:erl_tar` handles gzip and nothing else. The same xz encoder
// produces the `Packages.xz` the Debian guidance asks servers to
// offer.
//
// Conventions match native/gitgud_gix:
// * Returns {:ok, _} | {:error, reason :: binary}.
// * Everything crossing the boundary is a binary — armored ASCII in
// both directions, so the Elixir side never handles packets.
//
// Signing is CPU-bound but small (one signature over a few kilobytes
// of hashes), so the NIFs run on a dirty CPU scheduler rather than
// blocking a normal one.
use anyhow::{anyhow, Context, Result};
use rustler::{Binary, Encoder, Env, NifResult, OwnedBinary, Term};
use std::io::{Cursor, Read, Write};
use sequoia_openpgp as openpgp;
use openpgp::armor;
use openpgp::cert::prelude::*;
use openpgp::parse::Parse;
use openpgp::policy::StandardPolicy;
use openpgp::serialize::stream::{Armorer, Message, Signer};
use openpgp::serialize::SerializeInto;
use openpgp::types::KeyFlags;
mod atoms {
rustler::atoms! {
ok,
error,
}
}
/// Generate a signing-capable OpenPGP certificate.
///
/// Returns `{:ok, {public_armored, secret_armored}}`. The public half
/// is what lands in a client's `/etc/apt/keyrings`; the secret half is
/// stored encrypted by the caller and never leaves the forge.
///
/// The key is unencrypted at rest *as far as OpenPGP is concerned* —
/// there is no passphrase — because the thing protecting it is
/// `GitGud.Secrets`' AES-256-GCM envelope, and a passphrase we would
/// also have to store alongside it protects nothing.
#[rustler::nif(schedule = "DirtyCpu")]
fn generate_key<'a>(env: Env<'a>, user_id: String) -> NifResult<Term<'a>> {
wrap(env, || {
let (cert, _revocation) = CertBuilder::new()
.add_userid(user_id.as_str())
// Ed25519: small signatures, fast, and supported by every
// apt in the field via RFC 4880bis / RFC 9580.
.set_cipher_suite(CipherSuite::Cv25519)
.set_validity_period(None)
.add_subkey(KeyFlags::empty().set_signing(), None, None)
.generate()
.context("generating an OpenPGP certificate")?;
let public = String::from_utf8(cert.armored().to_vec()?)
.context("armoring the public certificate")?;
let secret = String::from_utf8(cert.as_tsk().armored().to_vec()?)
.context("armoring the secret key")?;
Ok((public, secret))
})
}
/// Clearsign `data` — the `InRelease` form apt prefers, where the
/// signature and the signed text are one file.
#[rustler::nif(schedule = "DirtyCpu")]
fn sign_clearsign<'a>(env: Env<'a>, secret_armored: String, data: String) -> NifResult<Term<'a>> {
wrap(env, || {
let cert = openpgp::Cert::from_bytes(secret_armored.as_bytes())
.context("reading the secret key")?;
let keypair = signing_keypair(&cert)?;
let mut sink = Vec::new();
let message = Message::new(&mut sink);
let mut signer = Signer::new(message, keypair)
.context("preparing the signer")?
.cleartext()
.build()
.context("starting the cleartext signature")?;
signer.write_all(data.as_bytes())?;
signer.finalize().context("finalizing the signature")?;
String::from_utf8(sink).context("the clearsigned output was not UTF-8")
})
}
/// Produce a detached, armored signature — the `Release.gpg` form,
/// which older apt still looks for when `InRelease` is absent.
#[rustler::nif(schedule = "DirtyCpu")]
fn sign_detached<'a>(env: Env<'a>, secret_armored: String, data: String) -> NifResult<Term<'a>> {
wrap(env, || {
let cert = openpgp::Cert::from_bytes(secret_armored.as_bytes())
.context("reading the secret key")?;
let keypair = signing_keypair(&cert)?;
let mut sink = Vec::new();
let message = Message::new(&mut sink);
let message = Armorer::new(message)
.kind(armor::Kind::Signature)
.build()
.context("starting the armorer")?;
let mut signer = Signer::new(message, keypair)
.context("preparing the signer")?
.detached()
.build()
.context("starting the detached signature")?;
signer.write_all(data.as_bytes())?;
signer.finalize().context("finalizing the signature")?;
String::from_utf8(sink).context("the armored signature was not UTF-8")
})
}
/// Verify a detached armored signature over `data` against a public
/// certificate. Not used to serve anything — it lets the tests check
/// what apt will check, rather than checking that we called our own
/// signer.
#[rustler::nif(schedule = "DirtyCpu")]
fn verify_detached<'a>(
env: Env<'a>,
public_armored: String,
data: String,
signature_armored: String,
) -> NifResult<Term<'a>> {
use openpgp::parse::stream::{
DetachedVerifierBuilder, MessageLayer, MessageStructure, VerificationHelper,
};
struct Helper(openpgp::Cert);
impl VerificationHelper for Helper {
fn get_certs(&mut self, _ids: &[openpgp::KeyHandle]) -> Result<Vec<openpgp::Cert>> {
Ok(vec![self.0.clone()])
}
fn check(&mut self, structure: MessageStructure) -> Result<()> {
for layer in structure.into_iter() {
if let MessageLayer::SignatureGroup { results } = layer {
if results.iter().any(|r| r.is_ok()) {
return Ok(());
}
}
}
Err(anyhow!("no good signature"))
}
}
wrap(env, || {
let cert =
openpgp::Cert::from_bytes(public_armored.as_bytes()).context("reading the key")?;
let policy = StandardPolicy::new();
let mut verifier = DetachedVerifierBuilder::from_bytes(signature_armored.as_bytes())
.context("reading the signature")?
.with_policy(&policy, None, Helper(cert))
.context("preparing the verifier")?;
verifier
.verify_bytes(data.as_bytes())
.context("verifying the signature")?;
Ok(true)
})
}
/// The first signing-capable, live, unrevoked secret key in the cert.
fn signing_keypair(cert: &openpgp::Cert) -> Result<openpgp::crypto::KeyPair> {
let policy = StandardPolicy::new();
let key = cert
.keys()
.secret()
.with_policy(&policy, None)
.supported()
.alive()
.revoked(false)
.for_signing()
.next()
.ok_or_else(|| anyhow!("the certificate has no usable signing key"))?
.key()
.clone();
key.into_keypair()
.context("the signing key is encrypted or otherwise unusable")
}
/// Map a `Result` onto the `{:ok, _} | {:error, binary}` shape the
/// Elixir side expects. Errors carry the whole `anyhow` context chain,
/// because "signing failed" without the reason is useless in a log.
fn wrap<'a, T, F>(env: Env<'a>, f: F) -> NifResult<Term<'a>>
where
T: Encoder,
F: FnOnce() -> Result<T>,
{
match f() {
Ok(value) => Ok((atoms::ok(), value).encode(env)),
Err(error) => Ok((atoms::error(), format!("{:#}", error)).encode(env)),
}
}
// ── compression ─────────────────────────────────────────────────────
/// Decompress an xz stream — a .deb's `control.tar.xz`.
#[rustler::nif(schedule = "DirtyCpu")]
fn xz_decompress<'a>(env: Env<'a>, data: Binary) -> NifResult<Term<'a>> {
wrap_binary(env, || {
let mut out = Vec::new();
lzma_rs::xz_decompress(&mut Cursor::new(data.as_slice()), &mut out)
.context("decompressing an xz stream")?;
Ok(out)
})
}
/// Compress to xz — the `Packages.xz` a repository is expected to
/// offer. Indices are small; this is not a hot path.
#[rustler::nif(schedule = "DirtyCpu")]
fn xz_compress<'a>(env: Env<'a>, data: Binary) -> NifResult<Term<'a>> {
wrap_binary(env, || {
let mut out = Vec::new();
lzma_rs::xz_compress(&mut Cursor::new(data.as_slice()), &mut out)
.context("compressing to xz")?;
Ok(out)
})
}
/// Decompress a zstd stream — a .deb's `control.tar.zst`, which is
/// what Ubuntu's dpkg produces.
#[rustler::nif(schedule = "DirtyCpu")]
fn zstd_decompress<'a>(env: Env<'a>, data: Binary) -> NifResult<Term<'a>> {
wrap_binary(env, || {
let mut decoder = ruzstd::StreamingDecoder::new(Cursor::new(data.as_slice()))
.map_err(|e| anyhow!("reading the zstd frame: {e}"))?;
let mut out = Vec::new();
decoder
.read_to_end(&mut out)
.context("decompressing a zstd stream")?;
Ok(out)
})
}
/// As `wrap`, but hands back a binary rather than a string — these
/// carry compressed bytes, which are not UTF-8.
fn wrap_binary<'a, F>(env: Env<'a>, f: F) -> NifResult<Term<'a>>
where
F: FnOnce() -> Result<Vec<u8>>,
{
match f() {
Ok(bytes) => {
let mut binary = OwnedBinary::new(bytes.len()).ok_or(rustler::Error::BadArg)?;
binary.as_mut_slice().copy_from_slice(&bytes);
Ok((atoms::ok(), Binary::from_owned(binary, env)).encode(env))
}
Err(error) => Ok((atoms::error(), format!("{:#}", error)).encode(env)),
}
}
rustler::init!("Elixir.GitGud.Packages.Apt.Native");
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.7 KiB · text
History
6bfb936