Initial scaffold: modern PGP replacement in Jerboa + Rust

ober

79499fdd558dd51dfb1a2972857c520c48a316e1

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4d91942
--- /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/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ba01f7d
--- /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
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..785bfd6
--- /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
+```
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5773a4f
--- /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
diff --git a/pgp-native/Cargo.toml b/pgp-native/Cargo.toml
new file mode 100644
index 0000000..41ad258
--- /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"
diff --git a/pgp-native/src/age_mod.rs b/pgp-native/src/age_mod.rs
new file mode 100644
index 0000000..efe66cc
--- /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)
+}
diff --git a/pgp-native/src/error.rs b/pgp-native/src/error.rs
new file mode 100644
index 0000000..d17b24a
--- /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;
diff --git a/pgp-native/src/lib.rs b/pgp-native/src/lib.rs
new file mode 100644
index 0000000..1c4442e
--- /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
+}
diff --git a/pgp-native/src/pass_mod.rs b/pgp-native/src/pass_mod.rs
new file mode 100644
index 0000000..bccb548
--- /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)
+}
diff --git a/pgp-native/src/pgp_mod.rs b/pgp-native/src/pgp_mod.rs
new file mode 100644
index 0000000..185391a
--- /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)
+}
diff --git a/pgp-native/src/sig_mod.rs b/pgp-native/src/sig_mod.rs
new file mode 100644
index 0000000..e596a57
--- /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()
+}
diff --git a/pgp-native/src/util.rs b/pgp-native/src/util.rs
new file mode 100644
index 0000000..b85d4e0
--- /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()
+}
diff --git a/pgp/armor.ss b/pgp/armor.ss
new file mode 100644
index 0000000..95ded1b
--- /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
diff --git a/pgp/cli.ss b/pgp/cli.ss
new file mode 100644
index 0000000..d6f3f95
--- /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))]