9.7 KiB · text History 6bfb936
// 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");