Add long-form documentation under docs/
ober
06e5e0c1f1f35c9d0878769fb054a9c7d65e487b
new file mode 100644 --- /dev/null +++ b/.gitsafe.json @@ -0,0 +1,14 @@ +{ + "severity": "medium", + "entropy": true, + "exclude": [ + "*.lock", + "go.sum", + "**/*.md", + "docs/**", + "vendor/**", + "node_modules/**", + "*.min.js", + "*.min.css" + ] +} --- a/PLAN.md +++ b/PLAN.md @@ -1,5 +1,9 @@ # jerboa-pgp — Plan +This file is the original design sketch, kept for historical record. For +the current state of the project see [README.md](README.md) and the +documentation under [`docs/`](docs/). + ## Motivation GPG has been around for 20+ years and almost nobody uses it. The data model @@ -22,10 +26,11 @@ who would otherwise like to use it. (`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. +5. **PGP as a thin interop layer.** You can encrypt *to* and decrypt + *from* an OpenPGP key, and produce or verify OpenPGP detached + signatures, but native messages are age. Anything beyond round-trip + (keyservers, web-of-trust, subkey management) is explicitly out of + scope. ## Architecture @@ -36,117 +41,92 @@ jerboa-pgp/ │ └── 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 +│ ├── util.rs # buffer-output helpers +│ ├── age_mod.rs # age keygen / encrypt / decrypt +│ ├── pass_mod.rs # age scrypt: identity-file wrap/unwrap +│ ├── sig_mod.rs # Ed25519 keygen / sign / verify +│ ├── pgp_mod.rs # rPGP: encrypt-to-PGP-recipient +│ └── pgp_io.rs # rPGP: decrypt, sign, verify ├── 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 +│ ├── armor.ss # ASCII-armor detect / classify +│ ├── recipient.ss # parse age vs jpgp1 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 +├── support/ +│ ├── binary-entry.ss # entry compiled into the static binary +│ └── build-binary.sh # WPO + boot-embed + link wrapper +├── doc/ +│ └── jpg.1 # mandoc man page +├── completions/ +│ ├── jpg.bash +│ └── _jpg ├── test/ -│ └── test-all.ss # smoke tests +│ ├── test-all.ss # smoke tests +│ └── interop-gpg.sh # bidirectional gpg interop test +├── docs/ # Long-form docs (see docs/README.md) ├── 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 +jpg keygen [--out PATH] Generate identity (prompts passphrase) +jpg pubkey [--identity P] [--out P] Print this identity's public key line +jpg list [--identity P] Show identity info +jpg fingerprint [--identity P] Print SHA256: fingerprint +jpg encrypt [-r R ...] [-s] [-i I] [-o O] Encrypt; auto-routes age/PGP +jpg decrypt [-s] [-i I] [-o O] [--identity P] [--pgp-key SEC.asc] [--pgp-pass P] +jpg sign [-i I] [-o O] [--identity P] [--pgp-key SEC.asc] [--pgp-pass P] +jpg verify SIG [-i I] [--pubkey P] [--pgp-pubkey PUB.asc] +jpg 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. +For full details see [`doc/jpg.1`](doc/jpg.1). + +## What v1 ships + +All items below are implemented and tested: + +- `keygen` / `pubkey` / `list` / `fingerprint` +- `encrypt` (age, multi-recipient, symmetric) and `encrypt -r FOO.asc` + for OpenPGP recipients +- `decrypt` (age, symmetric) and `decrypt --pgp-key SECRET.asc` for + inbound OpenPGP +- `sign` / `verify` (Ed25519 native) and `--pgp-key` variants that + produce / verify OpenPGP detached signatures +- Self-contained `jpg` binary built with Chez `compile-program` + WPO + + boot-file embedding (~5 MB Mach-O / ELF) +- Bidirectional gpg interop tested end-to-end (`make test-interop`) +- mandoc man page, bash + zsh completions + +## Out-of-scope (still) + +- **Keyservers / WKD.** Public keys are exchanged out-of-band (paste, + email, file). No fetch over the network. +- **Web of trust / certifications.** Public keys are identified by their + SHA-256 fingerprint; there is no third-party trust signing. +- **Subkey hierarchy.** Each identity has one age key and one Ed25519 + key. No rotation across subkeys, no signing of subkeys. +- **AEAD OpenPGP packets** (the gnupg-proprietary OCB packet, tag 20). + rPGP doesn't parse them. See [docs/INTEROP.md](docs/INTEROP.md) for + how to make gpg emit SEIPDv1 instead. +- **Agent / passphrase caching.** Each operation prompts. There is no + ssh-agent-style daemon. ## Build ``` make run ARGS='keygen' # interpreter make test # smoke tests -make binary # native binary `jpgp` -make install # → ~/.local/bin/jpgp +make test-interop # gpg interop +make binary # native binary `jpg` +make install # → ~/.local/bin/jpg + ~/.local/lib/libjpgp_native ``` + +See [docs/BUILDING.md](docs/BUILDING.md) for what the binary build +actually does. --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ v1 implements: - Self-contained `jpg-bin` (Chez `compile-program` + boot-embedded) - Bidirectional gpg interop tested end-to-end (`make test-interop`) -See `PLAN.md` for the design and out-of-scope items. +See [`docs/`](docs/) for the architecture, FFI surface, on-disk +formats, gpg interop notes, build internals, and threat model. +[`PLAN.md`](PLAN.md) is the original design sketch. ## Why new file mode 100644 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# Architecture + +`jerboa-pgp` is two halves stitched together by a small C ABI: + +- **`pgp-native/`** — a pure-Rust crate exposing all cryptographic + primitives behind a flat `extern "C"` surface. Built as + `cdylib` + `staticlib` (`libjpgp_native.{dylib,so}` + `.a`). +- **`pgp/`** — Jerboa code (Chez Scheme + `(jerboa prelude)`) providing + argument parsing, file I/O, recipient routing, identity-file + management, passphrase prompting, and shell-friendly behaviour. + +Nothing in `pgp/` does cryptography. Nothing in `pgp-native/` knows +about a CLI. The two never share heap memory — every value crossing the +boundary is either a primitive scalar, a length-prefixed byte slice, or +a NUL-terminated UTF-8 string. + +``` + +----------------------- jpg(1) --------------------+ + | | + argv -->| main.ss --> (pgp cli) --> (pgp identity) -etc. | + | | | + | v | + | (pgp ffi) foreign-procedure | + +------------------|--------------------------------+ + | C ABI (extern "C") + +------------------|--------------------------------+ + | v | + | libjpgp_native (Rust) | + | age_mod pass_mod sig_mod pgp_mod pgp_io | + | (age) (age-pw) (ed25519-dalek) (rPGP) | + +---------------------------------------------------+ +``` + +## Rust side: `pgp-native/src/` + +| File | Purpose | +|--------------|-------------------------------------------------------------| +| `lib.rs` | The whole `extern "C"` surface. Each entry point wraps its body in `catch_unwind` and routes the result through the `(buf, buf_len, *out_len)` pattern (see `util.rs::write_out`). | +| `error.rs` | `JPGP_OK = 0` plus the `JPGP_E_*` constants. These are stable; they're the only thing the Jerboa side sees on failure. | +| `util.rs` | `write_out` (the buffer-output helper), `slice_from` (turn `(ptr, len)` into a slice), `cstr_to_str`. All `unsafe` is concentrated here. | +| `age_mod.rs` | age X25519 keygen, recipient encrypt, recipient decrypt. Uses the `age` crate with armor enabled. | +| `pass_mod.rs`| Wraps/unwraps blobs with an age scrypt passphrase recipient. This is what protects the identity file. | +| `sig_mod.rs` | Ed25519 keygen / sign / verify (the `ed25519-dalek` crate). | +| `pgp_mod.rs` | Outbound OpenPGP: parse an armored public key, pick the encryption subkey (or primary), encrypt to SEIPDv1 with AES-256. | +| `pgp_io.rs` | Inbound OpenPGP and signing: decrypt SEIPDv1, sign data with a secret key, verify a detached signature against a public key.| + +### The FFI guard + +Every `extern "C"` entry point looks like this: + +```rust +pub unsafe extern "C" fn jpgp_age_encrypt(...) -> i32 { + guard(|| { + let pt = match unsafe { slice_from(plain, plain_len) } { + Some(s) => s, + None => return JPGP_E_INVALID_INPUT, + }; + // ... + }) +} + +fn guard<F: FnOnce() -> i32>(f: F) -> i32 { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(code) => code, + Err(_) => JPGP_E_INTERNAL, + } +} +``` + +A Rust panic must never unwind across the FFI boundary (that's undefined +behaviour). `guard` traps it and returns `JPGP_E_INTERNAL`. Combined +with `panic = "abort"` in the release profile, panic paths are +essentially impossible in production builds. + +### The release profile + +```toml +[profile.release] +opt-level = "z" # size, not speed +lto = true +codegen-units = 1 +strip = true +panic = "abort" +``` + +The Rust crate's final size matters more than its raw speed — most CPU +time in this tool is spent in the audited crypto primitives (which the +compiler already inlines and vectorises), not in our wrapping. Stripped +LTO yields a ~3 MB dylib. + +## Jerboa side: `pgp/` + +| Library | Purpose | +|-----------------|-------------------------------------------------------------| +| `(pgp util)` | `jpgp-error`, `bv->u32-le`, file-I/O helpers (`read-file-bytes`, `write-file-bytes`, `write-stdout-bytes`). All other libraries import from this. | +| `(pgp ffi)` | The only place that talks to `libjpgp_native`. Locates and loads the dylib, defines one `foreign-procedure` binding per Rust function, wraps each in a Scheme-shaped procedure that returns bytevectors / values. | +| `(pgp armor)` | Pure-text classifier: given a string, is it age-armored, a PGP pubkey block, a PGP message, a `jpgp1` line, or an age recipient? Used by `recipient.ss` and by `decrypt` to refuse the wrong tool. | +| `(pgp recipient)` | Parses recipient strings/files into tagged values: `('age S)`, `('jpgp ALIST)`, `('pgp ARMOR)`. Also formats outgoing `jpgp1` lines. | +| `(pgp identity)` | The `identity` defstruct (age sk/pk + Ed25519 sk/pk), `save-identity` / `load-identity` (which call `jpgp-pass-encrypt` / `jpgp-pass-decrypt`), and `identity-pubkey-line` (formats `jpgp1 age=... ed25519=...`). | +| `(pgp prompt)` | Reads a passphrase with `stty -echo`. `read-passphrase-confirm` loops until two reads match. | +| `(pgp cli)` | The whole user-facing surface. `parse-opts` (tiny flag parser), one `cmd-*` per subcommand, and a `run-cli` dispatcher. This is where format auto-detection happens (e.g. `decrypt` refuses an OpenPGP message unless `--pgp-key` was supplied). | +| `pgp/main.ss` | Script entry point. Sets `library-directories`, imports `(pgp cli)`, and calls `(run-cli (command-line-arguments))`. | + +### Library boundary discipline + +`(pgp cli)` never imports `(pgp ffi)` for anything that needs raw +bytes. Instead, all crypto goes through one of the higher-level +modules (`identity`, `recipient`, etc.), which in turn calls `ffi`. +This means: + +- A wrong-passphrase failure in `load-identity` comes back as a single + `(error 'jpgp-pass-decrypt "wrong passphrase")` — the CLI prints a + clean message instead of stack-tracing. +- Adding a new crypto operation is two edits: one in `(pgp ffi)` for + the binding, one in the relevant high-level module that gives it + Jerboa-shaped types. + +## Runtime flow + +### `jpg keygen --out PATH` + +``` +cmd-keygen (pgp cli) + read-passphrase-confirm (pgp prompt) + generate-identity (pgp identity) + jpgp-age-keygen (pgp ffi) → Rust age_mod::keygen + jpgp-ed25519-keygen (pgp ffi) → Rust sig_mod::keygen + save-identity (pgp identity) + identity->text (formats plaintext) + jpgp-pass-encrypt (pgp ffi) → Rust pass_mod::encrypt + write-file-bytes (pgp util) + system "chmod 600 ..." +``` + +### `jpg encrypt -r alice.pub.jpgp -i secret.txt -o secret.txt.age` + +``` +cmd-encrypt (pgp cli) + read-file-bytes "secret.txt" (pgp util) + recipient-from-file … (pgp recipient) + blob-kind / classify → 'jpgp-pubkey + parse-jpgp-pubkey-line → '((age . "age1…") (ed25519 . #vu8(…))) + (all-age-style? recs) → #t + jpgp-age-encrypt plain joined (pgp ffi) → Rust age_mod::encrypt + write-file-bytes "secret.txt.age" +``` + +### `jpg encrypt -r alice.asc -i secret.txt -o secret.asc` (PGP) + +Same path until `recipient-from-file`, which classifies the input as a +`pgp-pubkey`. Then `cmd-encrypt` takes the single-PGP-recipient branch: + +``` + jpgp-pgp-encrypt armor plain (pgp ffi) → Rust pgp_mod::encrypt + (SEIPDv1 / AES-256) +``` + +### `jpg decrypt -i secret.txt.age` (auto-detect) + +`cmd-decrypt` reads the ciphertext, decodes it as UTF-8 best-effort, +and uses `(pgp armor)` to see whether it's a PGP message. If it is, and +the user did not pass `--pgp-key`, the tool exits with a clear error +("re-run with --pgp-key SECRET.asc"). Otherwise it loads the identity +and calls `jpgp-age-decrypt`. + +### `jpg verify SIG -i FILE` + +`cmd-verify` reads `SIG` as text and inspects the armor header: + +- `-----BEGIN PGP SIGNATURE-----` → OpenPGP path, requires + `--pgp-pubkey PUB.asc`, calls `jpgp-pgp-verify`. +- Otherwise treats it as a `jpgp` signature blob (`-----BEGIN JPGP + SIGNATURE-----` with `pk:` and `sig:` lines), extracts the bytes, + and calls `jpgp-ed25519-verify`. + +If the user passes `--pubkey FILE`, the embedded `pk:` from the blob is +ignored in favour of the supplied `jpgp1` line — this defends against +the trivial attack of replacing the embedded pubkey with one whose +holder will sign anything. + +## The `(buf, buf_len, *out_len)` pattern + +Variable-length Rust output is returned through a caller-supplied +buffer: + +``` +i32 jpgp_X(...inputs..., u8* out, u32 out_buf_len, u32* out_len); +``` + +- `*out_len` is always written (even if `out` is null). +- If `out_len_in < needed`, returns `JPGP_E_INSUFFICIENT_BUFFER`. +- Otherwise writes `needed` bytes and returns `JPGP_OK`. + +The Jerboa side (`call-with-buffer` in `pgp/ffi.ss`) tries an initial +64 KB buffer, and if that's too small, looks at `*out_len` to retry +with an exactly-sized buffer. No allocator state is shared. + +See [FFI.md](FFI.md) for the full ABI. + +## Where the binary comes from + +`support/build-binary.sh` does what Jerboa's own `build-binary.sh` does, +except it passes *both* the project's lib directory and Jerboa's lib so +WPO can resolve `(pgp ...)` *and* `(jerboa prelude)` / `(std ...)`. The +output is a single static binary that bundles `petite.boot`, +`scheme.boot`, and a whole-program-optimised `program.boot` containing +all the project Scheme code. The native crypto lib still lives outside +the binary and is loaded at runtime — set `JPGP_NATIVE_LIB` to override +the search. + +See [BUILDING.md](BUILDING.md) for the step-by-step. new file mode 100644 --- /dev/null +++ b/docs/BUILDING.md @@ -0,0 +1,224 @@ +# Building + +There are three useful build targets: + +| Target | Result | +|----------------------|---------------------------------------------------------------------| +| `make build-native` | the Rust dylib only (`libjpgp_native.{dylib,so}`) | +| `make run ARGS=...` | run under the Chez interpreter (fast iteration, needs Jerboa repo) | +| `make binary` | self-contained `jpg-bin` (5 MB Mach-O / ELF) | +| `make install` | `binary` + copy to `~/.local/{bin,lib,share/man/man1}` | + +## Prerequisites + +- **Rust** (any 2021-edition stable) — built with `cargo`, no nightly. +- **Chez Scheme** built from source — `make binary` calls into Chez's + C API to embed boot files. Pre-built Chez packages from Linux distros + often omit the headers (`scheme.h`) and static lib (`libkernel.a`) + that the binary build needs. +- **Jerboa** checked out next to this repo (`~/mine/jerboa` by default, + or set `JERBOA_HOME`). The Jerboa stdlib (`lib/std/...`) is imported + by the project Scheme code. + +The Makefile assumes: +``` +JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa) +SCHEME ?= $(JERBOA_HOME)/.chez/bin/scheme +``` + +Both can be overridden on the make command line: + +``` +make binary JERBOA_HOME=/opt/jerboa SCHEME=/opt/chez/bin/scheme +``` + +## Development cycle (interpreter mode) + +``` +make build-native +make run ARGS='keygen --out /tmp/test.key' +make run ARGS='version' +make test +make test-interop +``` + +`make run` invokes Chez `--script` against `pgp/main.ss` with both the +project root and `$JERBOA_HOME/lib` on the `--libdirs` path. Sources +are compiled on demand into `.so` files alongside the `.ss` files (Chez +caches these), and `libjpgp_native.dylib` is dynamically loaded by +`(pgp ffi)` on first call. + +This path is fast — about 100 ms cold start including library compile +on a first invocation, then under 30 ms for subsequent runs. Good for +development; not what you want to ship. + +## The binary build + +`make binary` runs `support/build-binary.sh` and produces a single +~5 MB executable that bundles the Chez runtime and all project Scheme +code. It still needs `libjpgp_native.{dylib,so}` at runtime, loaded +from `~/.local/lib` (or the cargo target dir, or `$JPGP_NATIVE_LIB`). + +The script does four steps: + +### Step 1 — Whole-program optimisation + +``` +$SCHEME --libdirs "$JPGP_REPO:$JERBOA_HOME/lib" \ + --script "$JERBOA_HOME/support/build-boot.ss" \ + $ENTRY $WPO_SO $OBJ_DIR +``` + +Chez's `compile-whole-program` walks every `(import ...)` reachable +from `support/binary-entry.ss`, compiles each library to a `.wpo` +object, then merges them into a single `.wp.so` that is itself a +complete Chez program — no further library resolution needed at runtime. + +The `--libdirs` argument passes **both** the project root (so `(pgp +cli)` etc. resolve) and the Jerboa lib (so `(jerboa prelude)` and +`(std ...)` resolve). Jerboa's own `build-binary.sh` hardcodes only its +own lib path, which is why we ship our own wrapper rather than calling +it directly. + +`binary-entry.ss` is the entry point compiled in. It strips an optional +leading `--` from `command-line-arguments` (Chez `--script` leaves one +behind when invoked with `-- $(ARGS)`), then calls `(run-cli args)`. + +### Step 2 — Embed boot files as C arrays + +The Chez runtime expects to load `petite.boot` and `scheme.boot` from +disk on startup. To make the final binary self-contained, we embed +those (plus the just-built `program.wp.so`) as `static const unsigned +char` arrays in a generated header: + +``` +embed "$CSV_DIR/petite.boot" petite_boot +embed "$CSV_DIR/scheme.boot" scheme_boot +embed "$WPO_SO" program_boot +``` + +`$CSV_DIR` is the Chez installation's `csvN.N/<machine-type>` directory +— the script searches `/usr/local/lib`, `/opt/homebrew/lib`, +`$JERBOA_CHEZ_PREFIX/lib`, etc. for a directory containing +`libkernel.a`, `scheme.h`, and `petite.boot` simultaneously. For Jerboa +users, `$JERBOA_HOME/.chez/lib/csvN.N/<machine-type>` is the first +match. + +### Step 3 — Generate `main.c` + +A tiny C entry point: + +```c +int main(int argc, const char *argv[]) { + Sscheme_init(NULL); + Sregister_boot_file_bytes("petite", petite_boot_data, petite_boot_size); + Sregister_boot_file_bytes("scheme", scheme_boot_data, scheme_boot_size); + Sbuild_heap(NULL, NULL); + + const char *prog_path = write_program_tmpfile(); + Sscheme_program(prog_path, argc, argv); + unlink(prog_path); + + Sscheme_deinit(); + return 0; +} +``` + +The boot files are registered in-memory, the heap is built, then the +program boot is dumped to a tmp file and handed to `Sscheme_program`. +The tmp file is unlinked immediately after `Sscheme_program` returns +(or after `exit`, since `unlink` doesn't have to wait for the process +to exit on POSIX). + +### Step 4 — Compile and link + +``` +$CC -I$CSV_DIR -O2 \ + -o $OUTPUT $OUTPUT-main.c \ + $CSV_DIR/libkernel.a \ + [$CSV_DIR/liblz4.a $CSV_DIR/libz.a if present] \ + $OS_LIBS +``` + +`$OS_LIBS` depends on the OS: + +| OS | Libraries | +|----------|--------------------------------------------| +| Linux | `-lm -ldl -lpthread -ltinfo` (or `-lncurses`) | +| Darwin | `-lm -lpthread -lncurses -liconv` | +| FreeBSD | `-lm -lpthread -lncurses -L/usr/local/lib -liconv` | + +Compression libs (`liblz4`, `libz`) are linked only if present in +`$CSV_DIR` — Chez uses them to compress its boot files but the runtime +will fall back without them. + +The final binary is `-O2` for size/speed balance; bumping to `-Oz` +shaves another ~200 KB but slows startup measurably. + +## Install layout + +`make install` puts: + +``` +~/.local/bin/jpg (the binary, 5 MB) +~/.local/lib/libjpgp_native.{dylib,so} (Rust crypto, 3 MB) +~/.local/share/man/man1/jpg.1 (mandoc man page) +``` + +Shell completions are not installed automatically — copy them from +`completions/` to wherever your distro expects them: + +``` +completions/jpg.bash → ~/.local/share/bash-completion/completions/jpg +completions/_jpg → any directory in $fpath +``` + +`make install-script` is an alternative target that wraps the dev +interpreter as a shell stub (`exec $SCHEME --script $REPO/pgp/main.ss +-- "$@"`). It's useful when you want a `jpg` on `$PATH` but you're +iterating fast and don't want to rebuild the binary. The stub depends +on the source tree continuing to exist at the same path. + +## Static / musl builds + +The binary build is dynamic: `libkernel.a` is the only Chez-side static +input, but `libjpgp_native.dylib` is loaded at runtime and `$OS_LIBS` +links against system shared libraries. For a fully static musl build +you'd need to: + +- Build Chez against musl (non-trivial — Chez's bootstrapping uses gcc + intrinsics that musl handles, but the build scripts have Linux-glibc + assumptions). +- Build `libjpgp_native` as a `staticlib` and link it directly into the + final binary instead of dyloading. +- Replace `-lncurses` / `-ltinfo` with their static equivalents from + the musl toolchain. + +The Rust side is already configured as `crate-type = ["staticlib", +"cdylib"]`, so the `.a` is built every time. This isn't wired into the +Makefile yet — see [`pgp-native/Cargo.toml`](../pgp-native/Cargo.toml). + +## Reproducible builds + +The current build is not bit-for-bit reproducible. Embedded boot files +are deterministic given a fixed Chez install, and the Rust release +profile (`opt-level = "z"`, `lto = true`, `codegen-units = 1`) is +mostly deterministic given a pinned toolchain. The variable input is +the C compiler's use of timestamps and build-tree paths in the final +ELF/Mach-O. Pinning `SOURCE_DATE_EPOCH` and stripping with `strip -p` +would close the gap; not a priority for v1. + +## Cleaning + +``` +make clean +``` + +Removes: +- the Rust `target/` directory (`cargo clean`) +- compiled `.so` / `.dylib` outputs in the project tree +- `*.wpo` files +- `jpg-bin` and old `jpgp-bin` artifacts + +This does NOT remove user data (`~/.jpgp/identity.age`, +`~/.local/...`). new file mode 100644 --- /dev/null +++ b/docs/FFI.md @@ -0,0 +1,224 @@ +# FFI surface — `libjpgp_native` + +The Rust crate exposes a flat C ABI. Every entry point is `extern "C"`, +returns `i32` (a `JPGP_E_*` code), and uses the `(buf, buf_len, +*out_len)` pattern for variable-length output. + +The ABI is the only contract between the two halves. The Jerboa side +(`pgp/ffi.ss`) wraps each call so callers see normal Scheme values, but +the wire format below is what's actually crossed. + +## ABI version + +```c +uint32_t jpgp_abi_version(void); // currently returns 1 +``` + +Bumped on any incompatible change to the surface below. The Jerboa side +does not currently refuse to load on a mismatch — it's there so future +versions can. + +## Error codes + +Defined in [`pgp-native/src/error.rs`](../pgp-native/src/error.rs). + +| Code | Name | Meaning | +|------|-------------------------------|----------------------------------------------------| +| 0 | `JPGP_OK` | success | +| 1 | `JPGP_E_INTERNAL` | a Rust panic was caught | +| 2 | `JPGP_E_INVALID_INPUT` | a pointer was null when it shouldn't be, or a length was wrong | +| 3 | `JPGP_E_INSUFFICIENT_BUFFER` | caller's buffer too small; `*out_len` is the needed size | +| 4 | `JPGP_E_KEYGEN` | keygen RNG failure (vanishingly rare) | +| 5 | `JPGP_E_ENCRYPT` | underlying encrypt failed | +| 6 | `JPGP_E_DECRYPT` | underlying decrypt failed (bad ciphertext, wrong key) | +| 7 | `JPGP_E_BAD_PASSPHRASE` | scrypt unwrap with wrong passphrase | +| 8 | `JPGP_E_VERIFY` | a signature check returned false | +| 9 | `JPGP_E_PARSE_KEY` | a key blob (age or armored OpenPGP) wouldn't parse | +| 10 | `JPGP_E_NO_RECIPIENT` | recipient list was empty or contained nothing usable | +| 11 | `JPGP_E_PGP` | rPGP returned an error during composition | + +These constants are *stable*. Adding new codes is fine; renumbering +existing ones is not — the Jerboa side compares against the integer +values in `pgp/ffi.ss`. + +## The buffer-output convention + +For every call that returns variable-length data: + +```c +int32_t jpgp_X( + /* inputs */ ..., + uint8_t *out, + uint32_t out_buf_len, + uint32_t *out_len +); +``` + +Rules (implemented in `pgp-native/src/util.rs::write_out`): + +1. `*out_len` is always written, even when `out` is null. +2. If `out` is null, returns `JPGP_OK` after writing `*out_len` + (size-query path). +3. If `out_buf_len < *out_len`, returns `JPGP_E_INSUFFICIENT_BUFFER`. +4. Otherwise copies `*out_len` bytes into `out` and returns `JPGP_OK`. + +The Jerboa side starts with a 64 KB buffer +([`pgp/ffi.ss::initial-out-size`](../pgp/ffi.ss)) and retries with the +exactly-needed size if the first call returns +`JPGP_E_INSUFFICIENT_BUFFER`. No allocator state crosses the boundary. + +## The panic guard + +Every function wraps its body in `catch_unwind`: + +```rust +fn guard<F: FnOnce() -> i32>(f: F) -> i32 { + match catch_unwind(AssertUnwindSafe(f)) { + Ok(code) => code, + Err(_) => JPGP_E_INTERNAL, + } +} +``` + +Combined with `panic = "abort"` in the release profile, panic paths are +effectively unreachable. The guard exists so even debug builds can't +unwind across `extern "C"` (UB). + +## Functions + +Signatures below are reproduced from `pgp-native/src/lib.rs`. Pointer +parameters annotated `*const T` are read-only; `*mut T` are writable +for the advertised length. Slices may be `(NULL, 0)` for empty inputs. + +### age — keygen, encrypt, decrypt + +```c +int32_t jpgp_age_keygen( + uint8_t *sec_buf, uint32_t sec_buf_len, uint32_t *sec_out_len, + uint8_t *pub_buf, uint32_t pub_buf_len, uint32_t *pub_out_len); +``` + +Generates a fresh 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 rules. + +```c +int32_t jpgp_age_encrypt( + const uint8_t *plain, uint32_t plain_len, + const char *recipients_cstr, // newline-separated age1... + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); + +int32_t jpgp_age_decrypt( + const uint8_t *cipher, uint32_t cipher_len, + const char *identity_cstr, // "AGE-SECRET-KEY-1..." + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); +``` + +`recipients_cstr` is parsed line-by-line; blank lines and `#`-comment +lines are skipped. Output is ASCII-armored age. + +### Passphrase wrap/unwrap (used for the identity file) + +```c +int32_t jpgp_pass_encrypt( + const uint8_t *plain, uint32_t plain_len, + const char *passphrase_cstr, + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); + +int32_t jpgp_pass_decrypt( + const uint8_t *cipher, uint32_t cipher_len, + const char *passphrase_cstr, + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); +``` + +These wrap age's `scrypt` recipient mode. `_decrypt` returns +`JPGP_E_BAD_PASSPHRASE` on the wrong passphrase. + +### Ed25519 + +```c +int32_t jpgp_ed25519_keygen(uint8_t *out_sk /*32*/, uint8_t *out_pk /*32*/); + +int32_t jpgp_ed25519_sign( + const uint8_t *sk /*32*/, + const uint8_t *msg, uint32_t msg_len, + uint8_t *out_sig /*64*/); + +int32_t jpgp_ed25519_verify( + const uint8_t *pk /*32*/, + const uint8_t *msg, uint32_t msg_len, + const uint8_t *sig /*64*/); +``` + +`verify` returns `JPGP_OK` on a good signature, `JPGP_E_VERIFY` on a +bad one. All buffer sizes are fixed; there is no size-query path. + +### OpenPGP + +```c +int32_t jpgp_pgp_encrypt( + const char *pubkey_armor_cstr, // -----BEGIN PGP PUBLIC KEY BLOCK----- + const uint8_t *plain, uint32_t plain_len, + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); + +int32_t jpgp_pgp_decrypt( + const char *secret_armor_cstr, // -----BEGIN PGP PRIVATE KEY BLOCK----- + const char *passphrase_cstr, // "" if key is unencrypted + const uint8_t *cipher, uint32_t cipher_len, + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); + +int32_t jpgp_pgp_sign( + const char *secret_armor_cstr, + const char *passphrase_cstr, + const uint8_t *msg, uint32_t msg_len, + uint8_t *out, uint32_t out_buf_len, uint32_t *out_len); + +int32_t jpgp_pgp_verify( + const char *pubkey_armor_cstr, + const char *sig_armor_cstr, + const uint8_t *msg, uint32_t msg_len); +``` + +`pgp_encrypt` uses SEIPDv1 with AES-256, picking the first encryption +subkey or falling back to the primary key. `pgp_sign` produces a V4 +detached signature with SHA-256 and an `IssuerFingerprint` subpacket. +`pgp_verify` tries the primary then every subkey, returning `JPGP_OK` +on any match. + +The full rationale for SEIPDv1 (vs OCB) and the gpg-side +gymnastics is in [INTEROP.md](INTEROP.md). + +### SHA-256 + +```c +int32_t jpgp_sha256( + const uint8_t *input, uint32_t input_len, + uint8_t *out /*32*/); +``` + +Used for `jpg fingerprint`. We expose this rather than re-implementing +SHA-256 in Scheme because (a) the `sha2` crate is already in the +dependency graph for rPGP, and (b) we already trust the FFI surface for +much more sensitive primitives. + +## Lifetimes and aliasing + +No FFI call retains a pointer past its return. Inputs are consumed +synchronously; outputs are written into the caller's buffer. There is +no callback or async path. + +## Loading the library + +`pgp/ffi.ss` tries paths in this order, falling through to the next on +any error: + +1. `$JPGP_NATIVE_LIB` (if set) +2. `$JPGP_DIR/pgp-native/target/release/libjpgp_native.{dylib,so}` +3. `$JPGP_DIR/pgp-native/target/debug/...` +4. `/usr/local/lib/...` +5. `/opt/homebrew/lib/...` +6. `$HOME/.local/lib/...` +7. plain `libjpgp_native.{dylib,so}` (system loader) + +`(jpgp-available?)` returns `#f` if all paths failed, and every wrapper +calls `need-lib` first to give a clean error. new file mode 100644 --- /dev/null +++ b/docs/FORMATS.md @@ -0,0 +1,185 @@ +# On-disk formats + +`jerboa-pgp` deals with five distinct artifacts: + +1. The identity file +2. The `jpgp1` public-key line +3. age-armored ciphertext (native encryption) +4. `JPGP` signature blobs (native signatures) +5. OpenPGP armor (interop with gpg) + +This document specifies each format and points at the code that +produces or consumes it. + +## 1. Identity file — `~/.jpgp/identity.age` + +A passphrase-protected age blob. The outer layer is standard age armor +with a `scrypt` recipient (no recipient key bytes, just KDF +parameters): + +``` +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNjcnlwdCBlNHpEbnNlNExtN1RyZGdL +... +-----END AGE ENCRYPTED FILE----- +``` + +Decrypted, the plaintext is a small text record: + +``` +jpgp-identity v1 +age: AGE-SECRET-KEY-1QQPYHL45Z3M9LRMV43LXMW9F8L65EXPK0XNVTU2LJV30Q66NXFHSNAFV4R +age-pub: age1ydnymp4uvyfu46c3yph46m4qvfh4d8a8ssvw48tdpwwa2q72txuqz0fc8h +ed25519-sk: dGVzdC1zZWNyZXQta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMzM= +ed25519-pk: dGVzdC1wdWJsaWMta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMjI= +``` + +Field semantics: + +| Field | Required | Encoding | +|---------------|----------|-----------------------------------------------------| +| `jpgp-identity vN` | yes | first line, version stamp (currently `v1`) | +| `age:` | yes | age secret key (`AGE-SECRET-KEY-1…`, bech32) | +| `age-pub:` | no | age public key (`age1…`, bech32). Optional only because v0 files predate the field; new files always include it. | +| `ed25519-sk:` | yes | base64 of the 32-byte Ed25519 secret seed | +| `ed25519-pk:` | yes | base64 of the 32-byte Ed25519 public key | + +`#`-comments and unknown lines are ignored, so the format is +forward-compatible — adding a future `cv25519:` (or whatever) doesn't +break old `jpg` builds. + +The file is created `chmod 600` (best-effort via `system "chmod"`); it +should not be group- or world-readable. + +Code: `pgp/identity.ss` (`save-identity` / `load-identity` / +`parse-identity-text`). + +## 2. Public-key line — `jpgp1 ...` + +A single line, designed to paste into Slack, email, or +`authorized_keys`-style file the way an SSH key would: + +``` +jpgp1 age=age1ydnymp4uvyfu46c3yph46m4qvfh4d8a8ssvw48tdpwwa2q72txuqz0fc8h ed25519=dGVzdC1wdWJsaWMta2V5LWJ5dGVzLTMyLWNoYXJzLWxvbmcyMjI= +``` + +Grammar: + +``` +LINE := "jpgp1" SP (FIELD SP)* FIELD NL? +FIELD := KEY "=" VALUE +KEY := identifier (a-z, 0-9, '-') +VALUE := no SP, no '=' +``` + +Fields: + +| Key | Value | +|-----------|----------------------------------------------------------------------------------| +| `age` | age public key (`age1…`, bech32) | +| `ed25519` | base64 of the 32-byte Ed25519 verifying key | + +Lines containing keys the reader doesn't recognise are accepted (the +unknown fields are kept in the parsed alist but unused). This means +future fields like `cv25519=` can be added without bumping `jpgp1`. + +A bare `age1…` recipient on its own is also accepted as a recipient +input — `jpg encrypt -r age1…` works without wrapping it in `jpgp1`. +The classification happens in `(pgp armor)`: + +```scheme +(blob-kind "age1...") → 'age-recipient +(blob-kind "jpgp1 age=… ed25519=…") → 'jpgp-pubkey +(blob-kind "-----BEGIN PGP ...") → 'pgp-pubkey or 'pgp-cipher +(blob-kind "-----BEGIN AGE ...") → 'age-cipher