Initial scaffold: modern PGP replacement in Jerboa + Rust
ober
79499fdd558dd51dfb1a2972857c520c48a316e1
new file mode 100644 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Rust build artifacts +pgp-native/target/ +pgp-native/Cargo.lock + +# Jerboa / Chez compiled output +*.so +*.dylib +*.wpo +*.dll + +# Binary outputs +jpgp-bin +jpgp-musl +jpgp-macos +jpgp-musl.sha256 +jpgp-macos.sha256 +jpgp + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ + +# Test scratch +test/tmp/ new file mode 100644 --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) +SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme +BIN_DIR := $(HOME)/.local/bin + +NATIVE_DIR := $(CURDIR)/pgp-native +NATIVE_RELEASE := $(NATIVE_DIR)/target/release +ifeq ($(shell uname -s),Darwin) + NATIVE_LIB := $(NATIVE_RELEASE)/libjpgp_native.dylib +else + NATIVE_LIB := $(NATIVE_RELEASE)/libjpgp_native.so +endif + +.PHONY: help run test build-native binary install clean +.DEFAULT_GOAL := help + +help: + @echo "jerboa-pgp — modern PGP replacement" + @echo "" + @echo "Development:" + @echo " make build-native Build pure-Rust crypto backend" + @echo " make run ARGS='version' Run jpgp under the interpreter" + @echo " make test Run smoke tests" + @echo "" + @echo "Distribution:" + @echo " make binary Build native binary (requires Chez+Jerboa)" + @echo " make install Build + install to ~/.local/bin" + @echo " make clean Remove build artifacts" + @echo "" + @echo "Environment:" + @echo " JERBOA_HOME = $(JERBOA_HOME)" + @echo " SCHEME = $(SCHEME)" + +build-native: + cd $(NATIVE_DIR) && cargo build --release + @echo "" + @echo "Built $(NATIVE_LIB)" + +run: build-native + JERBOA_HOME=$(JERBOA_HOME) JPGP_DIR=$(CURDIR) \ + $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib \ + --script pgp/main.ss -- $(ARGS) + +test: build-native + JERBOA_HOME=$(JERBOA_HOME) JPGP_DIR=$(CURDIR) \ + $(SCHEME) -q --libdirs $(CURDIR):$(JERBOA_HOME)/lib \ + --script test/test-all.ss + +binary: build-native + @echo "Native binary build not yet implemented — use 'make run' or 'make install-script'" + +install-script: build-native + mkdir -p $(BIN_DIR) + printf '#!/bin/sh\nexec %s -q --libdirs %s:%s/lib --script %s/pgp/main.ss -- "$$@"\n' \ + "$(SCHEME)" "$(CURDIR)" "$(JERBOA_HOME)" "$(CURDIR)" \ + > $(BIN_DIR)/jpgp + chmod +x $(BIN_DIR)/jpgp + @echo "Installed jpgp launcher to $(BIN_DIR)/jpgp" + @echo "(This wraps the dev interpreter; for a static binary see PLAN.md)" + +clean: + cd $(NATIVE_DIR) && cargo clean + find . -name '*.so' -not -path './pgp-native/*' -delete + find . -name '*.dylib' -not -path './pgp-native/*' -delete + find . -name '*.wpo' -delete + rm -f jpgp-bin new file mode 100644 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,152 @@ +# jerboa-pgp — Plan + +## Motivation + +GPG has been around for 20+ years and almost nobody uses it. The data model +(subkeys, certificates, web of trust), the CLI (`--batch`, `--yes`, `--no-tty`, +`--pinentry-mode loopback`...), the agent socket dance, and the keyserver +ritual conspire to make encryption practically inaccessible to developers +who would otherwise like to use it. + +`jerboa-pgp` is a clean replacement that keeps the **use cases** of GPG +(encrypt, decrypt, sign, verify, manage keys) but discards its **artifacts**. + +## Design principles + +1. **Modern crypto by default.** age-style for encryption (X25519 + + ChaCha20-Poly1305 + scrypt for passphrases); Ed25519 for signing. +2. **One identity file, one passphrase.** No keyring directory, no agent + socket, no subkey hierarchy. The identity file is itself + passphrase-protected via age scrypt. +3. **Pure Rust, no C.** All crypto comes from audited pure-Rust crates + (`age`, `ed25519-dalek`, `pgp`/rPGP, `zeroize`). +4. **Jerboa for the human side.** CLI, file I/O, recipient parsing, + output formatting — all in `(jerboa prelude)` style. +5. **PGP as an outbound interop bolt-on.** You can encrypt *to* somebody's + OpenPGP public key (so they can decrypt with `gpg`), but native messages + are age. We do not implement inbound PGP decryption, PGP signing, or + PGP key management in v1. + +## Architecture + +``` +jerboa-pgp/ +├── pgp-native/ # Rust crate (cdylib + staticlib) +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # extern "C" FFI surface +│ ├── error.rs # JPGP_E_* error codes +│ ├── age_ops.rs # age keygen / encrypt / decrypt +│ ├── sig_ops.rs # Ed25519 keygen / sign / verify +│ ├── pgp_ops.rs # rPGP: encrypt-to-PGP-recipient +│ └── pass_ops.rs # age scrypt: identity-file wrap/unwrap +├── pgp/ # Jerboa source (.ss) +│ ├── util.ss # byte/string helpers, error type +│ ├── ffi.ss # foreign-procedure bindings to libjpgp_native +│ ├── armor.ss # ASCII-armor detect / strip +│ ├── recipient.ss # parse age vs OpenPGP recipients +│ ├── identity.ss # load/save passphrase-wrapped identity +│ ├── prompt.ss # passphrase prompt (no echo) +│ ├── cli.ss # subcommand dispatch + arg parsing +│ └── main.ss # script entry point +├── test/ +│ └── test-all.ss # smoke tests +├── Makefile +└── README.md +``` + +## FFI surface (Rust → Jerboa) + +All functions follow the buffer-output pattern from `jerboa-yubikey`: callers +pass `(buf, buf_len, *out_len)`. If `buf` is NULL only the required length +is written. Return is an `i32` `JPGP_E_*` code (0 = success). + +``` +jpgp_age_keygen(out_sec, sec_buf_len, *out_sec_len, out_pub, pub_buf_len, *out_pub_len) -> i32 +jpgp_age_encrypt(plain*, plain_len, recipients_cstr, out, buf_len, *out_len) -> i32 +jpgp_age_decrypt(cipher*, cipher_len, identity_cstr, out, buf_len, *out_len) -> i32 + +jpgp_ed25519_keygen(*out_sk[64], *out_pk[32]) -> i32 +jpgp_ed25519_sign(sk[64], msg*, msg_len, *out_sig[64]) -> i32 +jpgp_ed25519_verify(pk[32], msg*, msg_len, sig[64]) -> i32 // 0=ok, JPGP_E_VERIFY=fail + +jpgp_pgp_encrypt(pubkey_armor_cstr, plain*, plain_len, out, buf_len, *out_len) -> i32 + +jpgp_pass_encrypt(plain*, plain_len, passphrase_cstr, out, buf_len, *out_len) -> i32 +jpgp_pass_decrypt(cipher*, cipher_len, passphrase_cstr, out, buf_len, *out_len) -> i32 +``` + +All entry points are `extern "C"` and wrap their body in `catch_unwind` so a +panic never crosses the FFI boundary. + +## File formats + +### Identity file — `~/.jpgp/identity.age` + +An age-encrypted blob (passphrase recipient via scrypt). Plaintext: + +``` +jpgp-identity v1 +age: AGE-SECRET-KEY-1... +ed25519: <base64 of 64-byte secret key> +``` + +### Public key file — `*.pub.jpgp` + +Single line, designed to paste into Slack/email: + +``` +jpgp1 age=age1abcd... ed25519=base64... +``` + +OpenPGP public keys (`.asc`) are detected by their `-----BEGIN PGP PUBLIC KEY BLOCK-----` +header and routed to `jpgp_pgp_encrypt`. + +### Encrypted message + +Native: standard age armor (`-----BEGIN AGE ENCRYPTED FILE-----`). +PGP-recipient: standard OpenPGP armor (`-----BEGIN PGP MESSAGE-----`). + +### Signature file — `*.sig` + +``` +jpgp-sig v1 +pubkey: <base64 of 32-byte Ed25519 pubkey> +sig: <base64 of 64-byte Ed25519 signature> +``` + +## CLI + +``` +jpgp keygen [--out PATH] Generate identity (prompts passphrase) +jpgp pubkey [--out PATH] Print this identity's public key line +jpgp encrypt -r RECIPIENT [-i IN] [-o OUT] Encrypt; auto-routes age/PGP +jpgp decrypt [-i IN] [-o OUT] Decrypt (age only in v1) +jpgp sign [-i IN] [-o OUT.sig] Sign a file/stdin +jpgp verify SIG_FILE [-i IN] Verify +jpgp version +``` + +Out of scope for v1: `jpgp key list/import-pgp/export`, agent, decrypt-PGP, +sign-PGP, certifications, expiry, revocation. These can be added once the +core round-trip is solid. + +## Out-of-scope items + +- **Inbound PGP decryption.** You can't decrypt a PGP-encrypted message sent + to your old GPG key. (Future: `jpgp legacy-decrypt` that imports a GPG + secret key one-time.) +- **PGP signing.** Sign outputs are jpgp/minisign-style, not PGP. +- **Web of trust / certifications.** Public keys are identified by fingerprint + (or human nickname you assign locally). No third-party trust signing. +- **Keyservers.** Public keys are exchanged out-of-band (paste, email, + file). Future: an optional fetch from a contact's HTTPS URL. + +## Build + +``` +make run ARGS='keygen' # interpreter +make test # smoke tests +make binary # native binary `jpgp` +make install # → ~/.local/bin/jpgp +``` new file mode 100644 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# jerboa-pgp + +A modern, friendlier replacement for GPG, written in [Jerboa] with a pure-Rust +crypto backend. **No C dependencies.** + +``` +jpgp keygen +jpgp encrypt -r alice.pub.jpgp -i secret.txt -o secret.txt.age +jpgp decrypt -i secret.txt.age +jpgp sign -i release.tar.gz -o release.tar.gz.sig +jpgp verify release.tar.gz.sig -i release.tar.gz +``` + +Native crypto is [age]-style (X25519 + ChaCha20-Poly1305) for encryption and +Ed25519 for signing. For interop with the few remaining GPG users, `jpgp +encrypt -r alice.asc` recognises an OpenPGP public key and encrypts to it +in OpenPGP format using [rPGP] — so the recipient can decrypt with plain +`gpg`. + +## Status + +Pre-alpha. v1 implements: + +- `keygen` / `pubkey` +- `encrypt` / `decrypt` (age) +- `encrypt -r FOO.asc` (OpenPGP outbound interop via rPGP) +- `sign` / `verify` (Ed25519) + +See `PLAN.md` for the design and out-of-scope items. + +## Why + +GPG has been around for two decades and almost nobody uses it. The data +model, the CLI surface, and the agent socket dance make it practically +inaccessible. `jerboa-pgp` keeps the *use cases* of GPG and discards +its *artifacts*. + +- **One identity file, one passphrase.** No keyring directory, no agent. +- **Recipients are public-key strings**, not email addresses. Paste them + into Slack like an SSH key. +- **Pure Rust crypto, no C.** `age`, `ed25519-dalek`, `rpgp`, `zeroize`. +- **Jerboa for the human side.** CLI parsing, file I/O, formatting. + +## Build + +Requires Chez Scheme + [Jerboa] checked out at `~/mine/jerboa` (or set +`JERBOA_HOME`). + +``` +make run ARGS='version' # interpreter mode +make test # smoke tests +make binary # native `jpgp` binary +make install # → ~/.local/bin/jpgp +``` + +## License + +ISC + +[Jerboa]: https://git.sr.ht/~lisp/jerboa +[age]: https://github.com/FiloSottile/age +[rPGP]: https://github.com/rpgp/rpgp new file mode 100644 --- /dev/null +++ b/pgp-native/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "jpgp-native" +version = "0.1.0" +edition = "2021" +description = "Pure-Rust crypto backend for jerboa-pgp (age + Ed25519 + rPGP)" +license = "ISC" + +[lib] +name = "jpgp_native" +crate-type = ["staticlib", "cdylib"] + +[dependencies] +age = { version = "0.10", default-features = false, features = ["armor"] } +ed25519-dalek = { version = "2.1", default-features = false, features = ["rand_core", "std"] } +pgp = { version = "0.13", default-features = false } +zeroize = "1.7" +rand = "0.8" +rand_core = "0.6" +secrecy = "0.8" +base64 = "0.22" +sha2 = { version = "0.10", default-features = false } +smallvec = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" new file mode 100644 --- /dev/null +++ b/pgp-native/src/age_mod.rs @@ -0,0 +1,71 @@ +//! age operations: X25519 keygen + recipient encrypt/decrypt. + +use crate::error::*; +use age::secrecy::ExposeSecret; +use std::io::{Read, Write}; + +/// Generate a fresh age X25519 identity. +/// +/// Returns (secret_string, public_string) where: +/// secret_string is "AGE-SECRET-KEY-1..." +/// public_string is "age1..." +pub fn keygen() -> (String, String) { + let id = age::x25519::Identity::generate(); + let pubkey = id.to_public().to_string(); + let secret = id.to_string().expose_secret().to_owned(); + (secret, pubkey) +} + +/// Encrypt to one or more age recipients. `recipients` is parsed +/// from a single string with one `age1...` recipient per line +/// (blank lines and `#` comments are ignored). +pub fn encrypt(plaintext: &[u8], recipients_str: &str) -> Result<Vec<u8>, i32> { + let mut recs: Vec<Box<dyn age::Recipient + Send>> = Vec::new(); + for line in recipients_str.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let r: age::x25519::Recipient = line.parse().map_err(|_| JPGP_E_PARSE_KEY)?; + recs.push(Box::new(r)); + } + if recs.is_empty() { + return Err(JPGP_E_NO_RECIPIENT); + } + let encryptor = age::Encryptor::with_recipients(recs).ok_or(JPGP_E_ENCRYPT)?; + + let mut out: Vec<u8> = Vec::new(); + { + let armored = age::armor::ArmoredWriter::wrap_output(&mut out, age::armor::Format::AsciiArmor) + .map_err(|_| JPGP_E_ENCRYPT)?; + let mut writer = encryptor.wrap_output(armored).map_err(|_| JPGP_E_ENCRYPT)?; + writer.write_all(plaintext).map_err(|_| JPGP_E_ENCRYPT)?; + let armored = writer.finish().map_err(|_| JPGP_E_ENCRYPT)?; + armored.finish().map_err(|_| JPGP_E_ENCRYPT)?; + } + Ok(out) +} + +/// Decrypt an age-armored ciphertext using a single X25519 identity +/// supplied as an `AGE-SECRET-KEY-1...` string. +pub fn decrypt(ciphertext: &[u8], identity_str: &str) -> Result<Vec<u8>, i32> { + let id: age::x25519::Identity = identity_str + .parse() + .map_err(|_| JPGP_E_PARSE_KEY)?; + + let armored = age::armor::ArmoredReader::new(ciphertext); + let decryptor = age::Decryptor::new(armored).map_err(|_| JPGP_E_DECRYPT)?; + + let mut reader = match decryptor { + age::Decryptor::Recipients(d) => { + let identities: Vec<Box<dyn age::Identity>> = vec![Box::new(id)]; + d.decrypt(identities.iter().map(|b| b.as_ref())) + .map_err(|_| JPGP_E_DECRYPT)? + } + age::Decryptor::Passphrase(_) => return Err(JPGP_E_DECRYPT), + }; + + let mut plaintext = Vec::new(); + reader.read_to_end(&mut plaintext).map_err(|_| JPGP_E_DECRYPT)?; + Ok(plaintext) +} new file mode 100644 --- /dev/null +++ b/pgp-native/src/error.rs @@ -0,0 +1,20 @@ +//! Error codes returned across the C ABI. +//! +//! All FFI entry points return `i32`. Zero is success. Non-zero values are +//! one of the `JPGP_E_*` constants below. The Jerboa side maps these back +//! to user-friendly messages. + +#![allow(dead_code)] + +pub const JPGP_OK: i32 = 0; +pub const JPGP_E_INTERNAL: i32 = 1; +pub const JPGP_E_INVALID_INPUT: i32 = 2; +pub const JPGP_E_INSUFFICIENT_BUFFER: i32 = 3; +pub const JPGP_E_KEYGEN: i32 = 4; +pub const JPGP_E_ENCRYPT: i32 = 5; +pub const JPGP_E_DECRYPT: i32 = 6; +pub const JPGP_E_BAD_PASSPHRASE: i32 = 7; +pub const JPGP_E_VERIFY: i32 = 8; +pub const JPGP_E_PARSE_KEY: i32 = 9; +pub const JPGP_E_NO_RECIPIENT: i32 = 10; +pub const JPGP_E_PGP: i32 = 11; new file mode 100644 --- /dev/null +++ b/pgp-native/src/lib.rs @@ -0,0 +1,273 @@ +//! C ABI surface for jerboa-pgp. +//! +//! All entry points return an `i32` `JPGP_E_*` code from `error.rs`. +//! Variable-length output uses the (buf, buf_len, *out_len) pattern: pass +//! `buf = NULL` to query the required length without writing. +//! +//! Every `extern "C"` function wraps its body in `catch_unwind` so a panic +//! never crosses the FFI boundary. + +mod age_mod; +mod error; +mod pass_mod; +mod pgp_mod; +mod sig_mod; +mod util; + +use error::*; +use std::os::raw::c_char; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::slice; +use util::{cstr_to_str, slice_from, write_out}; + +fn guard<F: FnOnce() -> i32>(f: F) -> i32 { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(code) => code, + Err(_) => JPGP_E_INTERNAL, + } +} + +// ── age keygen ────────────────────────────────────────────────────────────── + +/// Generate a fresh age X25519 identity. +/// +/// Writes the secret string ("AGE-SECRET-KEY-1...") to `sec_buf` and the +/// public string ("age1...") to `pub_buf`. Both follow the buffer-output +/// pattern (pass NULL bufs to query sizes). +/// +/// # Safety +/// All `*mut` pointers must be writable for the lengths advertised. +#[no_mangle] +pub unsafe extern "C" fn jpgp_age_keygen( + sec_buf: *mut u8, + sec_buf_len: u32, + sec_out_len: *mut u32, + pub_buf: *mut u8, + pub_buf_len: u32, + pub_out_len: *mut u32, +) -> i32 { + guard(|| { + if sec_out_len.is_null() || pub_out_len.is_null() { + return JPGP_E_INVALID_INPUT; + } + let (sec, pubk) = age_mod::keygen(); + let s = unsafe { write_out(sec.as_bytes(), sec_buf, sec_buf_len, sec_out_len) }; + if s != JPGP_OK { + return s; + } + unsafe { write_out(pubk.as_bytes(), pub_buf, pub_buf_len, pub_out_len) } + }) +} + +// ── age encrypt / decrypt ─────────────────────────────────────────────────── + +/// Encrypt to a newline-separated list of age recipients. Output is +/// ASCII-armored age. +#[no_mangle] +pub unsafe extern "C" fn jpgp_age_encrypt( + plain: *const u8, + plain_len: u32, + recipients_cstr: *const c_char, + out: *mut u8, + out_buf_len: u32, + out_len: *mut u32, +) -> i32 { + guard(|| { + let pt = match unsafe { slice_from(plain, plain_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let recs = match unsafe { cstr_to_str(recipients_cstr) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + match age_mod::encrypt(pt, recs) { + Ok(ct) => unsafe { write_out(&ct, out, out_buf_len, out_len) }, + Err(code) => code, + } + }) +} + +/// Decrypt age-armored ciphertext using a single AGE-SECRET-KEY-1... identity. +#[no_mangle] +pub unsafe extern "C" fn jpgp_age_decrypt( + cipher: *const u8, + cipher_len: u32, + identity_cstr: *const c_char, + out: *mut u8, + out_buf_len: u32, + out_len: *mut u32, +) -> i32 { + guard(|| { + let ct = match unsafe { slice_from(cipher, cipher_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let id = match unsafe { cstr_to_str(identity_cstr) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + match age_mod::decrypt(ct, id) { + Ok(pt) => unsafe { write_out(&pt, out, out_buf_len, out_len) }, + Err(code) => code, + } + }) +} + +// ── passphrase wrap / unwrap (for identity file) ──────────────────────────── + +#[no_mangle] +pub unsafe extern "C" fn jpgp_pass_encrypt( + plain: *const u8, + plain_len: u32, + passphrase_cstr: *const c_char, + out: *mut u8, + out_buf_len: u32, + out_len: *mut u32, +) -> i32 { + guard(|| { + let pt = match unsafe { slice_from(plain, plain_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let pass = match unsafe { cstr_to_str(passphrase_cstr) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + match pass_mod::encrypt(pt, pass) { + Ok(ct) => unsafe { write_out(&ct, out, out_buf_len, out_len) }, + Err(code) => code, + } + }) +} + +#[no_mangle] +pub unsafe extern "C" fn jpgp_pass_decrypt( + cipher: *const u8, + cipher_len: u32, + passphrase_cstr: *const c_char, + out: *mut u8, + out_buf_len: u32, + out_len: *mut u32, +) -> i32 { + guard(|| { + let ct = match unsafe { slice_from(cipher, cipher_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let pass = match unsafe { cstr_to_str(passphrase_cstr) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + match pass_mod::decrypt(ct, pass) { + Ok(pt) => unsafe { write_out(&pt, out, out_buf_len, out_len) }, + Err(code) => code, + } + }) +} + +// ── Ed25519 ───────────────────────────────────────────────────────────────── + +/// Generate a fresh Ed25519 keypair. Writes 32 secret bytes to `out_sk` +/// and 32 public bytes to `out_pk`. +#[no_mangle] +pub unsafe extern "C" fn jpgp_ed25519_keygen(out_sk: *mut u8, out_pk: *mut u8) -> i32 { + guard(|| { + if out_sk.is_null() || out_pk.is_null() { + return JPGP_E_INVALID_INPUT; + } + let (sk, pk) = sig_mod::keygen(); + unsafe { + slice::from_raw_parts_mut(out_sk, 32).copy_from_slice(&sk); + slice::from_raw_parts_mut(out_pk, 32).copy_from_slice(&pk); + } + JPGP_OK + }) +} + +/// Sign `msg` with the 32-byte secret key. Writes 64 signature bytes to `out_sig`. +#[no_mangle] +pub unsafe extern "C" fn jpgp_ed25519_sign( + sk: *const u8, + msg: *const u8, + msg_len: u32, + out_sig: *mut u8, +) -> i32 { + guard(|| { + if sk.is_null() || out_sig.is_null() { + return JPGP_E_INVALID_INPUT; + } + let m = match unsafe { slice_from(msg, msg_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let sk_arr: [u8; 32] = unsafe { *(sk as *const [u8; 32]) }; + let sig = sig_mod::sign(&sk_arr, m); + unsafe { slice::from_raw_parts_mut(out_sig, 64).copy_from_slice(&sig) }; + JPGP_OK + }) +} + +/// Verify a 64-byte signature over `msg` using the 32-byte public key. +/// Returns JPGP_OK on success, JPGP_E_VERIFY on bad signature. +#[no_mangle] +pub unsafe extern "C" fn jpgp_ed25519_verify( + pk: *const u8, + msg: *const u8, + msg_len: u32, + sig: *const u8, +) -> i32 { + guard(|| { + if pk.is_null() || sig.is_null() { + return JPGP_E_INVALID_INPUT; + } + let m = match unsafe { slice_from(msg, msg_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let pk_arr: [u8; 32] = unsafe { *(pk as *const [u8; 32]) }; + let sig_arr: [u8; 64] = unsafe { *(sig as *const [u8; 64]) }; + if sig_mod::verify(&pk_arr, m, &sig_arr) { + JPGP_OK + } else { + JPGP_E_VERIFY + } + }) +} + +// ── OpenPGP outbound encrypt ──────────────────────────────────────────────── + +/// Encrypt `plain` to an ASCII-armored OpenPGP public key. +/// Output is ASCII-armored OpenPGP (`-----BEGIN PGP MESSAGE-----`). +#[no_mangle] +pub unsafe extern "C" fn jpgp_pgp_encrypt( + pubkey_armor_cstr: *const c_char, + plain: *const u8, + plain_len: u32, + out: *mut u8, + out_buf_len: u32, + out_len: *mut u32, +) -> i32 { + guard(|| { + let pk = match unsafe { cstr_to_str(pubkey_armor_cstr) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + let pt = match unsafe { slice_from(plain, plain_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + match pgp_mod::encrypt(pk, pt) { + Ok(ct) => unsafe { write_out(ct.as_bytes(), out, out_buf_len, out_len) }, + Err(code) => code, + } + }) +} + +// ── ABI version stamp ─────────────────────────────────────────────────────── + +/// ABI version. Bump when the FFI surface changes incompatibly. +#[no_mangle] +pub extern "C" fn jpgp_abi_version() -> u32 { + 1 +} new file mode 100644 --- /dev/null +++ b/pgp-native/src/pass_mod.rs @@ -0,0 +1,41 @@ +//! Passphrase-protected encryption using age's scrypt-recipient mode. +//! +//! Used to wrap the identity file: a single age blob whose plaintext +//! contains both the X25519 secret key and the Ed25519 secret key. + +use crate::error::*; +use age::secrecy::SecretString; +use std::io::{Read, Write}; + +pub fn encrypt(plaintext: &[u8], passphrase: &str) -> Result<Vec<u8>, i32> { + let pass = SecretString::new(passphrase.to_owned()); + let encryptor = age::Encryptor::with_user_passphrase(pass); + + let mut out: Vec<u8> = Vec::new(); + { + let armored = age::armor::ArmoredWriter::wrap_output(&mut out, age::armor::Format::AsciiArmor) + .map_err(|_| JPGP_E_ENCRYPT)?; + let mut writer = encryptor.wrap_output(armored).map_err(|_| JPGP_E_ENCRYPT)?; + writer.write_all(plaintext).map_err(|_| JPGP_E_ENCRYPT)?; + let armored = writer.finish().map_err(|_| JPGP_E_ENCRYPT)?; + armored.finish().map_err(|_| JPGP_E_ENCRYPT)?; + } + Ok(out) +} + +pub fn decrypt(ciphertext: &[u8], passphrase: &str) -> Result<Vec<u8>, i32> { + let pass = SecretString::new(passphrase.to_owned()); + let armored = age::armor::ArmoredReader::new(ciphertext); + let decryptor = age::Decryptor::new(armored).map_err(|_| JPGP_E_DECRYPT)?; + + let mut reader = match decryptor { + age::Decryptor::Passphrase(d) => { + d.decrypt(&pass, None).map_err(|_| JPGP_E_BAD_PASSPHRASE)? + } + age::Decryptor::Recipients(_) => return Err(JPGP_E_DECRYPT), + }; + + let mut plaintext = Vec::new(); + reader.read_to_end(&mut plaintext).map_err(|_| JPGP_E_DECRYPT)?; + Ok(plaintext) +} new file mode 100644 --- /dev/null +++ b/pgp-native/src/pgp_mod.rs @@ -0,0 +1,42 @@ +//! OpenPGP outbound compatibility: encrypt-to-PGP-recipient via rPGP. +//! +//! v1 is encrypt-only — we never decrypt OpenPGP or sign in OpenPGP form. + +use crate::error::*; +use pgp::composed::{Deserializable, Message, SignedPublicKey}; +use pgp::crypto::sym::SymmetricKeyAlgorithm; +use pgp::types::KeyTrait; +use rand::thread_rng; + +/// Encrypt `plaintext` to an OpenPGP public key supplied as an ASCII-armored +/// `-----BEGIN PGP PUBLIC KEY BLOCK-----` string. Output is also ASCII-armored +/// (`-----BEGIN PGP MESSAGE-----`). +pub fn encrypt(pubkey_armor: &str, plaintext: &[u8]) -> Result<String, i32> { + let (pkey, _headers) = SignedPublicKey::from_armor_single(pubkey_armor.as_bytes()) + .map_err(|_| JPGP_E_PARSE_KEY)?; + pkey.verify().map_err(|_| JPGP_E_PARSE_KEY)?; + + let lit = Message::new_literal_bytes("msg.bin", plaintext); + let mut rng = thread_rng(); + + // Prefer an encryption subkey if present; otherwise use the primary. + let primary_can_encrypt = pkey.primary_key.is_encryption_key(); + let enc_subkey = pkey + .public_subkeys + .iter() + .find(|sk| sk.is_encryption_key()); + + let encrypted = if let Some(sub) = enc_subkey { + lit.encrypt_to_keys(&mut rng, SymmetricKeyAlgorithm::AES256, &[sub]) + .map_err(|_| JPGP_E_PGP)? + } else if primary_can_encrypt { + lit.encrypt_to_keys(&mut rng, SymmetricKeyAlgorithm::AES256, &[&pkey.primary_key]) + .map_err(|_| JPGP_E_PGP)? + } else { + return Err(JPGP_E_NO_RECIPIENT); + }; + + encrypted + .to_armored_string(pgp::composed::ArmorOptions::default()) + .map_err(|_| JPGP_E_PGP) +} new file mode 100644 --- /dev/null +++ b/pgp-native/src/sig_mod.rs @@ -0,0 +1,25 @@ +//! Ed25519 signing. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::rngs::OsRng; + +pub fn keygen() -> (Vec<u8>, Vec<u8>) { + let sk = SigningKey::generate(&mut OsRng); + let vk: VerifyingKey = sk.verifying_key(); + (sk.to_bytes().to_vec(), vk.to_bytes().to_vec()) +} + +pub fn sign(sk_bytes: &[u8; 32], msg: &[u8]) -> [u8; 64] { + let sk = SigningKey::from_bytes(sk_bytes); + let sig: Signature = sk.sign(msg); + sig.to_bytes() +} + +pub fn verify(pk_bytes: &[u8; 32], msg: &[u8], sig_bytes: &[u8; 64]) -> bool { + let vk = match VerifyingKey::from_bytes(pk_bytes) { + Ok(v) => v, + Err(_) => return false, + }; + let sig = Signature::from_bytes(sig_bytes); + vk.verify(msg, &sig).is_ok() +} new file mode 100644 --- /dev/null +++ b/pgp-native/src/util.rs @@ -0,0 +1,60 @@ +//! Shared helpers: writing variable-length output through the +//! (buf, buf_len, *out_len) FFI pattern. + +use crate::error::*; +use std::slice; + +/// Write `src` into the caller-provided buffer. +/// +/// - Writes `src.len()` to `*out_len` always (even when `out` is null). +/// - If `out` is null, returns `JPGP_OK` (caller is querying size). +/// - If `out_len_in` < `src.len()`, returns `JPGP_E_INSUFFICIENT_BUFFER`. +/// - Otherwise copies and returns `JPGP_OK`. +/// +/// # Safety +/// `out_len` must be a writable `*mut u32`. `out` (if non-null) must be +/// writable for `out_len_in` bytes. +pub unsafe fn write_out(src: &[u8], out: *mut u8, out_len_in: u32, out_len: *mut u32) -> i32 { + if out_len.is_null() { + return JPGP_E_INVALID_INPUT; + } + let need = src.len() as u32; + unsafe { *out_len = need }; + if out.is_null() { + return JPGP_OK; + } + if out_len_in < need { + return JPGP_E_INSUFFICIENT_BUFFER; + } + unsafe { + slice::from_raw_parts_mut(out, need as usize).copy_from_slice(src); + } + JPGP_OK +} + +/// Borrow a byte slice from a (ptr, len) pair. Returns an empty slice +/// when ptr is null and len is 0; returns None on null+nonzero or +/// otherwise invalid input. +/// +/// # Safety +/// `ptr` (if non-null) must be readable for `len` bytes. +pub unsafe fn slice_from(ptr: *const u8, len: u32) -> Option<&'static [u8]> { + if ptr.is_null() { + if len == 0 { + return Some(&[]); + } + return None; + } + Some(unsafe { slice::from_raw_parts(ptr, len as usize) }) +} + +/// Read a NUL-terminated UTF-8 C string into a `&str`. +/// +/// # Safety +/// `ptr` must be a valid NUL-terminated C string. +pub unsafe fn cstr_to_str<'a>(ptr: *const std::os::raw::c_char) -> Option<&'a str> { + if ptr.is_null() { + return None; + } + unsafe { std::ffi::CStr::from_ptr(ptr) }.to_str().ok() +} new file mode 100644 --- /dev/null +++ b/pgp/armor.ss @@ -0,0 +1,62 @@ +#!chezscheme +;;; (pgp armor) — Detect format of a key or ciphertext blob. + +(library (pgp armor) + (export + blob-kind + age-armored-ciphertext? + pgp-pubkey-armor? + pgp-message-armor? + jpgp-pubkey-line? + age-recipient-string?) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (pgp util)) + + (def (string-contains? s sub) + (let ([n (string-length s)] + [m (string-length sub)]) + (and (>= n m) + (let loop ([i 0]) + (cond + [(> (+ i m) n) #f] + [(string=? sub (substring s i (+ i m))) #t] + [else (loop (+ i 1))]))))) + + (def (age-armored-ciphertext? s) + (string-prefix? "-----BEGIN AGE ENCRYPTED FILE-----" (string-trim s))) + + (def (pgp-pubkey-armor? s) + (string-prefix? "-----BEGIN PGP PUBLIC KEY BLOCK-----" (string-trim s))) + + (def (pgp-message-armor? s) + (string-prefix? "-----BEGIN PGP MESSAGE-----" (string-trim s))) + + (def (jpgp-pubkey-line? s) + (string-prefix? "jpgp1 " (string-trim s))) + + (def (age-recipient-string? s) + (string-prefix? "age1" (string-trim s))) + + ;; Best-effort guess of what we're looking at. + ;; Returns one of: 'age-cipher, 'pgp-pubkey, 'pgp-cipher, + ;; 'jpgp-pubkey, 'age-recipient, 'unknown. + (def (blob-kind s) + (cond + [(age-armored-ciphertext? s) 'age-cipher] + [(pgp-pubkey-armor? s) 'pgp-pubkey] + [(pgp-message-armor? s) 'pgp-cipher] + [(jpgp-pubkey-line? s) 'jpgp-pubkey] + [(age-recipient-string? s) 'age-recipient] + [else 'unknown])) + + ) ;; end library new file mode 100644 --- /dev/null +++ b/pgp/cli.ss @@ -0,0 +1,300 @@ +#!chezscheme +;;; (pgp cli) — Subcommand dispatch + arg parsing. +;;; +;;; Exposed as a single (run-cli args) entry point so main.ss is just +;;; a thin script wrapper. + +(library (pgp cli) + (export run-cli jpgp-version-string) + + (import (except (chezscheme) + make-hash-table hash-table? + sort sort! + printf fprintf + path-extension path-absolute? + with-input-from-string with-output-to-string + iota 1+ 1- + partition + make-date make-time) + (except (jerboa prelude) meta atom?) + (only (std text base64) u8vector->base64-string base64-string->u8vector) + (pgp util) + (pgp ffi) + (pgp armor) + (pgp recipient) + (pgp identity) + (pgp prompt)) + + (def jpgp-version-string "jpgp 0.1.0") + + ;; ── Tiny option parser ───────────────────────────────────────────────── + ;; Returns (alist . positional). known-flags is a list of + ;; (flag-string . takes-value?). + (def (flag-arg? s) + (and (string? s) + (> (string-length s) 1) + (char=? (string-ref s 0) #\-) + (not (string=? s "-")))) + + (def (parse-opts args known-flags) + (let loop ([xs args] [opts '()] [pos '()]) + (cond + [(null? xs) (cons (reverse opts) (reverse pos))]