Resolve security audit findings
ober
281eae6bf12359766c50fc9a845520fa6213c63c
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: build-test-audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system tools run: | --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/.jerbuild +++ b/.jerbuild @@ -1,12 +1,9 @@ ;; Build jpg with a standalone jerbuild binary. (entry "support/binary-entry.ss") -(output "jpg") -(requires "cc" "cargo" "bundled jerboa-native-rs") -(notes "Builds pgp-native plus jerboa-native-rs with tls,sqlite,crypto. DuckDB is not required. The legacy OS-specific scripts still document extra static FFI symbol registration details.") +(output "jpg-bin") +(requires "cc" "cargo") +(notes "Builds pgp-native as a static archive and registers only its reviewed C ABI symbols. No runtime native-library search is used.") (libdirs ".") -(rust-crates - "pgp-native/Cargo.toml" - ("@bundle/jerboa-native-rs/Cargo.toml" - features: "tls,sqlite,crypto" - no-default-features: #t)) +(ffi-symbols "support/ffi-symbols.txt") +(rust-crates "pgp-native/Cargo.toml") --- a/Makefile +++ b/Makefile @@ -59,14 +59,18 @@ ensure-jerboa-tools: fi run: build-native ensure-jerboa-tools - JERBOA_HOME=$(JERBOA_HOME) JPGP_DIR=$(CURDIR) \ + JERBOA_HOME=$(JERBOA_HOME) JPGP_ALLOW_DYNAMIC_NATIVE=1 JPGP_DIR=$(CURDIR) \ $(JERBUILD) exec --libdirs $(CURDIR):$(JERBOA_HOME)/lib \ pgp/main.ss -- $(ARGS) test: build-native ensure-jerboa-tools - JERBOA_HOME=$(JERBOA_HOME) JPGP_DIR=$(CURDIR) \ + cd $(NATIVE_DIR) && cargo test --release + JERBOA_HOME=$(JERBOA_HOME) JPGP_ALLOW_DYNAMIC_NATIVE=1 JPGP_DIR=$(CURDIR) \ $(JERBUILD) exec --libdirs $(CURDIR):$(JERBOA_HOME)/lib \ test/test-all.ss + JERBUILD="$(JERBUILD)" JERBOA_HOME="$(JERBOA_HOME)" \ + JPGP_ALLOW_DYNAMIC_NATIVE=1 JPGP_DIR="$(CURDIR)" \ + sh test/verify-cli.sh test-interop: build-native JERBOA_HOME=$(JERBOA_HOME) JPGP_REPO=$(CURDIR) SCHEME=$(SCHEME) \ @@ -104,16 +108,15 @@ BINARY_OUTPUT ?= jpg-bin binary: build-native @echo "==> Building self-contained $(BINARY_OUTPUT) binary" JPGP_REPO=$(CURDIR) JERBOA_HOME=$(JERBOA_HOME) \ - $(JERBUILD) binary --libdirs "$(CURDIR):$(JERBOA_HOME)/lib" support/binary-entry.ss $(BINARY_OUTPUT) + $(JERBUILD) build --config .jerbuild + @if [ "$(BINARY_OUTPUT)" != "jpg-bin" ]; then cp jpg-bin "$(BINARY_OUTPUT)"; fi @echo "" - @echo "Built ./$(BINARY_OUTPUT). The native lib is still required at runtime:" - @echo " $(NATIVE_LIB)" - @echo " /usr/local/lib/libjpgp_native.{dylib,so}" - @echo " /opt/homebrew/lib/libjpgp_native.{dylib,so}" - @echo " or set JPGP_NATIVE_LIB=/path/to/libjpgp_native.{dylib,so}" + @echo "Built ./$(BINARY_OUTPUT) with libjpgp_native statically linked." binary-version-smoke: binary - JPGP_DIR=$(CURDIR) JPGP_NATIVE_LIB="$(NATIVE_LIB)" ./$(BINARY_OUTPUT) version + ./$(BINARY_OUTPUT) version + JPGP_DIR="$(CURDIR)" JPGP_BINARY="$(CURDIR)/$(BINARY_OUTPUT)" CC="$(CC)" \ + sh test/binary-loader-security.sh sbom: ensure-jerboa-tools @REPO_ROOT="$(CURDIR)" \ @@ -154,8 +157,8 @@ release-evidence: install-script: build-native mkdir -p $(BIN_DIR) $(MAN_DIR) - printf '#!/bin/sh\nexec %s -q --libdirs %s:%s/lib --script %s/pgp/main.ss -- "$$@"\n' \ - "$(SCHEME)" "$(CURDIR)" "$(JERBOA_HOME)" "$(CURDIR)" \ + printf '#!/bin/sh\nJPGP_ALLOW_DYNAMIC_NATIVE=1 JPGP_DIR=%s exec %s -q --libdirs %s:%s/lib --script %s/pgp/main.ss -- "$$@"\n' \ + "$(CURDIR)" "$(SCHEME)" "$(CURDIR)" "$(JERBOA_HOME)" "$(CURDIR)" \ > $(BIN_DIR)/jpg chmod +x $(BIN_DIR)/jpg cp doc/jpg.1 $(MAN_DIR)/jpg.1 @@ -169,15 +172,13 @@ install-script: build-native @echo "(This wraps the dev interpreter; for a static binary see PLAN.md)" install: binary - mkdir -p $(BIN_DIR) $(LIB_DIR) $(MAN_DIR) + mkdir -p $(BIN_DIR) $(MAN_DIR) cp $(BINARY_OUTPUT) $(BIN_DIR)/jpg chmod +x $(BIN_DIR)/jpg - cp $(NATIVE_LIB) $(LIB_DIR)/$(notdir $(NATIVE_LIB)) cp doc/jpg.1 $(MAN_DIR)/jpg.1 @echo "" @echo "Installed:" @echo " $(BIN_DIR)/jpg" - @echo " $(LIB_DIR)/$(notdir $(NATIVE_LIB))" @echo " $(MAN_DIR)/jpg.1" @echo "" @echo "Shell completions are in completions/ — copy to your distro's path:" --- a/PLAN.md +++ b/PLAN.md @@ -82,7 +82,8 @@ jpg fingerprint [--identity P] Print SHA256: fingerprint jpg encrypt [-r R ...] [-s] [-i I] [-o O] Encrypt to age/jpgp recipients jpg decrypt [-s] [-i I] [-o O] [--identity P] jpg sign [-i I] [-o O] [--identity P] -jpg verify SIG [-i I] [--pubkey P] +jpg verify SIG [-i I] --pubkey P +jpg verify SIG [-i I] --trust-embedded-key # untrusted check, exits 3 jpg version ``` @@ -121,7 +122,7 @@ make run ARGS='keygen' # interpreter make test # smoke tests make test-interop # disabled OpenPGP interop status make binary # native binary `jpg` -make install # → ~/.local/bin/jpg + ~/.local/lib/libjpgp_native +make install # → ~/.local/bin/jpg (native crypto is linked in) ``` See [docs/BUILDING.md](docs/BUILDING.md) for what the binary build --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ jpg keygen jpg encrypt -r alice.pub.jpgp -i secret.txt -o secret.txt.age jpg decrypt -i secret.txt.age jpg sign -i release.tar.gz -o release.tar.gz.sig -jpg verify release.tar.gz.sig -i release.tar.gz +jpg verify release.tar.gz.sig -i release.tar.gz --pubkey release-signer.pub.jpgp ``` Native crypto is [age]-style (X25519 + ChaCha20-Poly1305) for encryption and @@ -58,7 +58,7 @@ make audit # cargo-audit for the Rust backend make test-interop # reports OpenPGP interop disabled/skipped make binary # native `jpg` binary (5MB Mach-O / ELF) make release-evidence # tests, audit, SBOM, reproducibility -make install # → ~/.local/bin/jpg + ~/.local/lib/libjpgp_native +make install # → ~/.local/bin/jpg (native crypto is linked in) ``` Release evidence is documented in --- a/completions/_jpg +++ b/completions/_jpg @@ -74,6 +74,7 @@ _jpg() { '-i[input file]:file:_files' \ '--in[input file]:file:_files' \ '--pubkey[jpgp1 pubkey]:file:_files' \ + '--trust-embedded-key[untrusted embedded-key self-check]' \ '*:signature file:_files' ;; esac --- a/completions/jpg.bash +++ b/completions/jpg.bash @@ -52,7 +52,7 @@ _jpg() { -i|--in|--pubkey) _filedir ; return ;; verify) _filedir ; return ;; esac - COMPREPLY=( $(compgen -W "-i --in --pubkey" -- "$cur") ) + COMPREPLY=( $(compgen -W "-i --in --pubkey --trust-embedded-key" -- "$cur") ) ;; esac } --- a/doc/jpg.1 +++ b/doc/jpg.1 @@ -93,14 +93,18 @@ OpenPGP signing options are rejected in production builds. .Cm verify .Ar SIG .Op Fl i Ar IN -.Op Fl -pubkey Ar FILE +.Fl -pubkey Ar FILE .Xc Verify the signature at .Ar SIG against .Ar IN . -jpgp blobs are verified with the embedded pubkey -.Pq or one supplied via Fl -pubkey . +Trusted verification requires an externally authenticated key supplied with +.Fl -pubkey . +The alternative +.Fl -trust-embedded-key +performs an untrusted self-consistency check and exits with status 3 even when +the signature is cryptographically valid. OpenPGP signatures are detected and rejected. Exit status is 0 on success, 2 on bad signature. .It Cm version @@ -136,6 +140,7 @@ $ jpg encrypt -r age1xyz... -i secrets.txt -o secrets.age A bad signature in .Cm verify exits with status 2. +An untrusted embedded-key self-check exits with status 3. .Sh INTEROPERABILITY .Nm does not currently interoperate with GnuPG. --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -159,7 +159,7 @@ best-effort, and uses `(pgp armor)` to see whether it is an OpenPGP message. If it is, the tool exits with the disabled-OpenPGP error. Otherwise it calls `jpgp-age-decrypt`. -### `jpg verify SIG -i FILE` +### `jpg verify SIG -i FILE --pubkey SIGNER.jpgp` `cmd-verify` reads `SIG` as text and inspects the armor header: @@ -168,10 +168,11 @@ If it is, the tool exits with the disabled-OpenPGP error. Otherwise it calls 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. +Trusted verification requires `--pubkey FILE`; the selected `jpgp1` key must +also match the blob's embedded metadata. `--trust-embedded-key` is a separate +self-consistency mode that reports `trust=untrusted-embedded` and exits 3 even +when the signature is valid. Every outcome includes a stable `JPGP_VERIFY` +status line with result, trust source, and signer fingerprint. ## The `(buf, buf_len, *out_len)` pattern @@ -197,10 +198,8 @@ See [FFI.md](FFI.md) for the full ABI. `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. +output is a single static binary that bundles `petite.boot`, `scheme.boot`, a +whole-program-optimised `program.boot`, and the Rust native crypto archive. +Runtime library search is disabled in the production artifact. See [BUILDING.md](BUILDING.md) for the step-by-step. --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -49,8 +49,9 @@ strict-audit-clean OpenPGP backend exists. `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. +caches these). `make run` explicitly enables the development-only loader and +points it at the repository's absolute Cargo target path; ordinary launches do +not search the working directory or platform loader path. 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 @@ -58,10 +59,12 @@ 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`). +`make binary` invokes the checked-in `.jerbuild` configuration, which links the +Rust crate and registers the allowlisted `support/ffi-symbols.txt` ABI, and +produces a single +~5 MB executable that bundles the Chez runtime, all project Scheme code, and +the Rust `libjpgp_native` static archive. It does not search for or load a +native crypto library at runtime. The script does four steps: @@ -165,7 +168,6 @@ shaves another ~200 KB but slows startup measurably. ``` ~/.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) ``` @@ -185,16 +187,13 @@ 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: +The normal binary embeds `libjpgp_native.a`, but Chez and `$OS_LIBS` may still +link against system shared libraries. For a fully static musl build you still +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. --- a/docs/FFI.md +++ b/docs/FFI.md @@ -11,7 +11,7 @@ the wire format below is what's actually crossed. ## ABI version ```c -uint32_t jpgp_abi_version(void); // currently returns 1 +uint32_t jpgp_abi_version(void); // currently returns 2 ``` Bumped on any incompatible change to the surface below. The Jerboa side @@ -138,21 +138,25 @@ These wrap age's `scrypt` recipient mode. `_decrypt` returns ### Ed25519 ```c -int32_t jpgp_ed25519_keygen(uint8_t *out_sk /*32*/, uint8_t *out_pk /*32*/); +int32_t jpgp_ed25519_keygen( + uint8_t *out_sk, uint32_t out_sk_len, + uint8_t *out_pk, uint32_t out_pk_len); int32_t jpgp_ed25519_sign( - const uint8_t *sk /*32*/, + const uint8_t *sk, uint32_t sk_len, const uint8_t *msg, uint32_t msg_len, - uint8_t *out_sig /*64*/); + uint8_t *out_sig, uint32_t out_sig_len); int32_t jpgp_ed25519_verify( - const uint8_t *pk /*32*/, + const uint8_t *pk, uint32_t pk_len, const uint8_t *msg, uint32_t msg_len, - const uint8_t *sig /*64*/); + const uint8_t *sig, uint32_t sig_len); ``` `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. +bad one. The ABI requires the key/signature capacities to be exactly 32/64 +bytes and returns `JPGP_E_INVALID_INPUT` before constructing Rust slices when +they differ. ### OpenPGP compatibility stubs @@ -207,16 +211,13 @@ no callback or async path. ## Loading the library -`pgp/ffi.ss` tries paths in this order, falling through to the next on -any error: +Release binaries link `libjpgp_native` statically and resolve only the symbols +registered by the executable. There is no CWD, `HOME`, installation-prefix, or +bare-name fallback. -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. +Interpreter-based development must explicitly set +`JPGP_ALLOW_DYNAMIC_NATIVE=1` and either an absolute `JPGP_NATIVE_LIB` file or +an absolute `JPGP_DIR` repository root. The loader rejects relative paths, +symlinks (including symlinked path components), and all dynamic loading in root +or real/effective-ID-mismatched processes. This development escape hatch is +not a supported production deployment mechanism. --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -156,11 +156,12 @@ encoding, no headers — Ed25519 hashes its full input internally). The blob's verifier byte stream must match the original byte-for-byte for verification to succeed. -`pk:` is embedded for convenience. By default `jpg verify` uses the -embedded `pk:` to verify, which means a "signature OK" line by itself -only proves the signer **claimed** this key. If you care that the -signer is a specific party, supply `--pubkey FILE` so verification -checks against an externally-trusted `jpgp1` line. +`pk:` is embedded for diagnostics, not identity. Trusted `jpg verify` requires +`--pubkey FILE` pointing at an externally authenticated `jpgp1` line. An +explicit `--trust-embedded-key` performs only a self-consistency check, prints +`trust=untrusted-embedded`, and exits 3 even for a valid signature. The parser +rejects duplicate fields, invalid base64, blobs over 16 KiB, and decoded keys +or signatures whose lengths are not exactly 32/64 bytes. Code: `pgp/cli.ss` (`format-sig-blob`, `parse-sig-blob`, `cmd-verify`). --- a/docs/THREAT-MODEL.md +++ b/docs/THREAT-MODEL.md @@ -167,15 +167,13 @@ sig: <base64 of Ed25519 signature> -----END JPGP SIGNATURE----- ``` -This is **convenience, not trust**. By default `jpg verify` uses the -embedded `pk:` — which means a "Signature OK" line by itself just -means "this signer signed this with the key they claim to hold." It -proves nothing about whose key that is. - -For meaningful verification, pass `--pubkey FILE` pointing at a +This is **diagnostic metadata, not trust**. `jpg verify` therefore refuses to +report trusted success unless `--pubkey FILE` points at a `jpgp1` line you've authenticated out-of-band (paste, SSH-style -TOFU on first contact, fingerprint exchange). `cmd-verify` then -ignores the embedded `pk:` in favour of the supplied one. +TOFU on first contact, fingerprint exchange). The embedded key must also match +the pinned key. `--trust-embedded-key` is an explicit self-consistency mode; +it reports `UNTRUSTED SELF-SIGNATURE`, emits `trust=untrusted-embedded`, and +returns status 3 rather than trusted success. This is the same property as `ssh-keygen -Y verify`, which is fine for release artifact signing if users actually pin a key. We do not @@ -188,15 +186,15 @@ provide a known_hosts-equivalent yet. | Read identity file only | Nothing — file is scrypt-encrypted | | Identity file + passphrase | Full secret keys. Equivalent to compromising the user. | | Read process memory | Plaintext keys + passphrase while the process is running | -| Replace `libjpgp_native`| Full control — silently weaken keygen, leak signed data, etc. | +| Replace a development `libjpgp_native`| Full control in an explicitly opted-in interpreter run | | Replace `jpg` binary | Same as above | | Supply OpenPGP armor | Operation is rejected; no OpenPGP runtime parser is linked | | MITM during `verify` | Cannot forge a signature; can only cause false `BAD` | -The `libjpgp_native` replacement risk is why the Makefile installs it -to `~/.local/lib` (user-writable but not world-writable) and the dylib -search order in `pgp/ffi.ss` looks at user-owned paths before system -paths. There is no signed-binary check; that's a packaging concern. +Release binaries statically link `libjpgp_native` and perform no runtime native +library search. Interpreter development requires an explicit opt-in plus an +absolute, canonical repository/file path; it is rejected for privileged +processes and is not a production packaging mechanism. ## What we deliberately don't do --- a/pgp-native/src/lib.rs +++ b/pgp-native/src/lib.rs @@ -170,9 +170,14 @@ pub unsafe extern "C" fn jpgp_pass_decrypt( /// 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 { +pub unsafe extern "C" fn jpgp_ed25519_keygen( + out_sk: *mut u8, + out_sk_len: u32, + out_pk: *mut u8, + out_pk_len: u32, +) -> i32 { guard(|| { - if out_sk.is_null() || out_pk.is_null() { + if out_sk.is_null() || out_pk.is_null() || out_sk_len != 32 || out_pk_len != 32 { return JPGP_E_INVALID_INPUT; } let (sk, pk) = sig_mod::keygen(); @@ -188,20 +193,29 @@ pub unsafe extern "C" fn jpgp_ed25519_keygen(out_sk: *mut u8, out_pk: *mut u8) - #[no_mangle] pub unsafe extern "C" fn jpgp_ed25519_sign( sk: *const u8, + sk_len: u32, msg: *const u8, msg_len: u32, out_sig: *mut u8, + out_sig_len: u32, ) -> i32 { guard(|| { - if sk.is_null() || out_sig.is_null() { + if out_sig.is_null() || out_sig_len != 64 { return JPGP_E_INVALID_INPUT; } + let sk_slice = match unsafe { slice_from(sk, sk_len) } { + Some(s) if s.len() == 32 => s, + _ => 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); + let sk_arr: &[u8; 32] = match sk_slice.try_into() { + Ok(key) => key, + Err(_) => return JPGP_E_INVALID_INPUT, + }; + let sig = sig_mod::sign(sk_arr, m); unsafe { slice::from_raw_parts_mut(out_sig, 64).copy_from_slice(&sig) }; JPGP_OK }) @@ -212,21 +226,34 @@ pub unsafe extern "C" fn jpgp_ed25519_sign( #[no_mangle] pub unsafe extern "C" fn jpgp_ed25519_verify( pk: *const u8, + pk_len: u32, msg: *const u8, msg_len: u32, sig: *const u8, + sig_len: u32, ) -> i32 { guard(|| { - if pk.is_null() || sig.is_null() { - return JPGP_E_INVALID_INPUT; - } + let pk_slice = match unsafe { slice_from(pk, pk_len) } { + Some(s) if s.len() == 32 => s, + _ => return JPGP_E_INVALID_INPUT, + }; + let sig_slice = match unsafe { slice_from(sig, sig_len) } { + Some(s) if s.len() == 64 => s, + _ => 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) { + let pk_arr: &[u8; 32] = match pk_slice.try_into() { + Ok(key) => key, + Err(_) => return JPGP_E_INVALID_INPUT, + }; + let sig_arr: &[u8; 64] = match sig_slice.try_into() { + Ok(signature) => signature, + Err(_) => return JPGP_E_INVALID_INPUT, + }; + if sig_mod::verify(pk_arr, m, sig_arr) { JPGP_OK } else { JPGP_E_VERIFY @@ -333,5 +360,114 @@ pub unsafe extern "C" fn jpgp_sha256(input: *const u8, input_len: u32, out: *mut /// ABI version. Bump when the FFI surface changes incompatibly. #[no_mangle] pub extern "C" fn jpgp_abi_version() -> u32 { - 1 + 2 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ed25519_ffi_rejects_wrong_fixed_lengths() { + let mut sk = [0u8; 32]; + let mut pk = [0u8; 32]; + let mut sig = [0u8; 64]; + let msg = b"ffi boundary"; + + unsafe { + assert_eq!( + jpgp_ed25519_keygen(sk.as_mut_ptr(), 31, pk.as_mut_ptr(), 32), + JPGP_E_INVALID_INPUT + ); + assert_eq!( + jpgp_ed25519_keygen(sk.as_mut_ptr(), 32, pk.as_mut_ptr(), 32), + JPGP_OK + ); + assert_eq!( + jpgp_ed25519_keygen(std::ptr::null_mut(), 32, pk.as_mut_ptr(), 32), + JPGP_E_INVALID_INPUT + ); + assert_eq!( + jpgp_ed25519_sign( + sk.as_ptr(), + 32, + msg.as_ptr(), + msg.len() as u32, + sig.as_mut_ptr(), + 64, + ), + JPGP_OK + ); + assert_eq!( + jpgp_ed25519_verify( + pk.as_ptr(), + 32, + msg.as_ptr(), + msg.len() as u32, + sig.as_ptr(), + 64, + ), + JPGP_OK + ); + for bad_len in [0, 1, 31, 33] { + assert_eq!( + jpgp_ed25519_sign( + sk.as_ptr(), + bad_len, + msg.as_ptr(), + msg.len() as u32, + sig.as_mut_ptr(), + 64, + ), + JPGP_E_INVALID_INPUT + ); + assert_eq!( + jpgp_ed25519_verify( + pk.as_ptr(), + bad_len, + msg.as_ptr(), + msg.len() as u32, + sig.as_ptr(), + 64, + ), + JPGP_E_INVALID_INPUT + ); + } + for bad_len in [0, 1, 63, 65] { + assert_eq!( + jpgp_ed25519_verify( + pk.as_ptr(), + 32, + msg.as_ptr(), + msg.len() as u32, + sig.as_ptr(), + bad_len, + ), + JPGP_E_INVALID_INPUT + ); + } + assert_eq!( + jpgp_ed25519_sign( + sk.as_ptr(), + 32, + msg.as_ptr(), + msg.len() as u32, + sig.as_mut_ptr(), + 63, + ), + JPGP_E_INVALID_INPUT + ); + assert_eq!( + jpgp_ed25519_verify( + std::ptr::null(), + 32, + msg.as_ptr(), + msg.len() as u32, + sig.as_ptr(), + 64, + ), + JPGP_E_INVALID_INPUT + ); + } + } } --- a/pgp/cli.ss +++ b/pgp/cli.ss @@ -5,7 +5,7 @@ ;;; a thin script wrapper. (library (pgp cli) - (export run-cli jpgp-version-string) + (export run-cli jpgp-version-string format-sig-blob parse-sig-blob) (import (rnrs) (except (jerboa prelude) @@ -110,8 +110,12 @@ Commands: sign --pgp-key SECRET.asc [--pgp-pass PASS] [-i IN] [-o OUT] Rejected in production builds; OpenPGP interop is disabled. - verify SIG [-i IN] [--pubkey FILE] - Verify a jpgp signature against IN. + verify SIG [-i IN] --pubkey FILE + Verify a jpgp signature against an externally trusted key. + + verify SIG [-i IN] --trust-embedded-key + Check only self-consistency using the signature's embedded key. + Reports an untrusted status and exits 3 even when cryptographically valid. version Print version. @@ -309,25 +313,62 @@ Globally, -i/--in '-' means stdin and -o/--out '-' means stdout. "sig: " (u8vector->base64-string sig-bv) "\n" "-----END JPGP SIGNATURE-----\n")) + (def max-sig-blob-size 16384) + + (def (decode-fixed-sig-field field encoded expected-len) + (when (> (string-length encoded) 256) + (jpgp-error 'parse-sig "~a field is too large" field)) + (let ([decoded + (guard (e [#t (jpgp-error 'parse-sig "invalid base64 in ~a field" field)]) + (base64-string->u8vector encoded))]) + (unless (= (bytevector-length decoded) expected-len) + (jpgp-error 'parse-sig "~a field must decode to exactly ~a bytes" + field expected-len)) + decoded)) + (def (parse-sig-blob text) + (when (> (string-length text) max-sig-blob-size) + (jpgp-error 'parse-sig "signature blob exceeds ~a bytes" max-sig-blob-size)) (let loop ([lines (string-split text #\newline)] - [pk #f] [sig #f]) + [header? #f] [version? #f] [pk #f] [sig #f] [footer? #f]) (cond [(null? lines) - (unless (and pk sig) + (unless (and header? version? pk sig footer?) (jpgp-error 'parse-sig "malformed signature blob")) (values pk sig)] [else (let ([line (string-trim (car lines))]) (cond - [(string-prefix? "pk: " line) - (loop (cdr lines) - (base64-string->u8vector (substring line 4 (string-length line))) - sig)] - [(string-prefix? "sig: " line) - (loop (cdr lines) pk - (base64-string->u8vector (substring line 5 (string-length line))))] - [else (loop (cdr lines) pk sig)]))]))) + [(string=? line "") + (loop (cdr lines) header? version? pk sig footer?)] + [(string=? line "-----BEGIN JPGP SIGNATURE-----") + (when (or header? version? pk sig footer?) + (jpgp-error 'parse-sig "misplaced or duplicate signature header")) + (loop (cdr lines) #t version? pk sig footer?)] + [(string=? line "v: 1") + (unless (and header? (not version?) (not pk) (not sig) (not footer?)) + (jpgp-error 'parse-sig "misplaced or duplicate version field")) + (loop (cdr lines) header? #t pk sig footer?)] + [(string-prefix? "pk:" line) + (unless (and header? version? (not pk) (not footer?)) + (jpgp-error 'parse-sig "misplaced or duplicate pk field")) + (loop (cdr lines) header? version? + (decode-fixed-sig-field + "pk" (string-trim (substring line 3 (string-length line))) 32) + sig footer?)] + [(string-prefix? "sig:" line) + (unless (and header? version? (not sig) (not footer?)) + (jpgp-error 'parse-sig "misplaced or duplicate sig field")) + (loop (cdr lines) header? version? pk + (decode-fixed-sig-field + "sig" (string-trim (substring line 4 (string-length line))) 64) + footer?)] + [(string=? line "-----END JPGP SIGNATURE-----") + (unless (and header? version? pk sig (not footer?)) + (jpgp-error 'parse-sig "misplaced or duplicate signature footer")) + (loop (cdr lines) header? version? pk sig #t)] + [else + (jpgp-error 'parse-sig "unexpected signature line: ~a" line)]))]))) (def (cmd-sign rest) (let* ([parsed (parse-opts rest @@ -356,10 +397,21 @@ Globally, -i/--in '-' means stdin and -o/--out '-' means stdout. (def (pgp-signature-armor? s) (string-prefix? "-----BEGIN PGP SIGNATURE-----" (string-trim s))) + (def (signature-key-fingerprint pk) + (string-append "SHA256:" + (strip-trailing-padding + (u8vector->base64-string (jpgp-sha256 pk))))) + + (def (emit-verify-status result trust pk) + (fprintf (current-error-port) + "JPGP_VERIFY result=~a trust=~a signer=~a~%" + result trust (signature-key-fingerprint pk))) + (def (cmd-verify rest) (let* ([parsed (parse-opts rest '(("-i" . #t) ("--in" . #t) ("--pubkey" . #t) + ("--trust-embedded-key" . #f) ("--pgp-pubkey" . #t)))] [opts (car parsed)] [pos (cdr parsed)] @@ -367,31 +419,62 @@ Globally, -i/--in '-' means stdin and -o/--out '-' means stdout. [(pair? pos) (car pos)] [else (die "verify: SIG_FILE required")])] [inp (or (opt opts "-i") (opt opts "--in") "-")] - [sig-text (utf8->string (read-file-bytes sig-path))]) - (cond - [(pgp-signature-armor? sig-text) - (openpgp-disabled "verify")] - [else - (let ([msg (read-file-bytes inp)]) - (let-values ([(pk-claim sig) (parse-sig-blob sig-text)]) - (let ([pk (cond - [(opt opts "--pubkey") - => (lambda (p) - (let* ([raw (utf8->string (read-file-bytes p))] - [rec (recipient-from-string raw)]) - (case (car rec) - [(jpgp) - (cond - [(assq 'ed25519 (cadr rec)) => cdr] - [else (die "pubkey file missing ed25519 field")])] - [else (die "--pubkey must point to a jpgp1 line")])))] - [else pk-claim])]) - (cond - [(jpgp-ed25519-verify pk msg sig) - (display "Signature OK\n" (current-error-port))] - [else - (display "Signature BAD\n" (current-error-port)) - (exit 2)]))))]))) + [sig-bytes (read-file-bytes sig-path)] + [pinned-path (opt opts "--pubkey")] + [trust-embedded? (opt opts "--trust-embedded-key")]) + (when (> (bytevector-length sig-bytes) max-sig-blob-size) + (die "verify: signature blob exceeds ~a bytes" max-sig-blob-size)) + (when (and pinned-path trust-embedded?) + (die "verify: --pubkey and --trust-embedded-key are mutually exclusive")) + (unless (or pinned-path trust-embedded?) + (die "verify: --pubkey FILE is required for trusted verification (or explicitly use --trust-embedded-key for an untrusted self-check)")) + (let ([sig-text (utf8->string sig-bytes)]) + (cond + [(pgp-signature-armor? sig-text) + (openpgp-disabled "verify")] + [else + (let ([msg (read-file-bytes inp)]) + (let-values ([(pk-claim sig) (parse-sig-blob sig-text)]) + (let ([pk + (cond + [pinned-path + (let* ([raw (utf8->string (read-file-bytes pinned-path))] + [rec (recipient-from-string raw)]) + (case (car rec) + [(jpgp) + (cond + [(assq 'ed25519 (cadr rec)) => cdr] + [else (die "pubkey file missing ed25519 field")])] + [else (die "--pubkey must point to a jpgp1 line")]))] + [else pk-claim])]) + (unless (= (bytevector-length pk) 32) + (die "verify: selected public key must be exactly 32 bytes")) + (when (and pinned-path (not (bytevector=? pk pk-claim))) + (display "Signature BAD (embedded key does not match pinned signer)\n" + (current-error-port)) + (emit-verify-status "metadata-mismatch" "pinned" pk) + (exit 2)) + (cond + [(jpgp-ed25519-verify pk msg sig) + (if pinned-path + (begin + (fprintf (current-error-port) + "Signature OK (trusted pinned key ~a)~%" + (signature-key-fingerprint pk)) + (emit-verify-status "valid" "pinned" pk)) + (begin + (fprintf (current-error-port) + "UNTRUSTED SELF-SIGNATURE (embedded key ~a)~%" + (signature-key-fingerprint pk)) + (emit-verify-status "valid" "untrusted-embedded" pk) + (exit 3)))] + [else + (display "Signature BAD\n" (current-error-port)) + (emit-verify-status + "invalid" + (if pinned-path "pinned" "untrusted-embedded") + pk) + (exit 2)]))))])))) ;; ── dispatcher ───────────────────────────────────────────────────────── --- a/pgp/ffi.ss +++ b/pgp/ffi.ss @@ -31,7 +31,11 @@ read-file-string path-join) (only (jerboa core) getenv) (only (jerboa ffi) c-lambda load-shared-object*) + (only (std os exec-id) exec-id-realpath-of) (only (std os platform) platform-macos?) + (only (std os posix) posix-getuid posix-geteuid) + (only (chezscheme) + file-symbolic-link? foreign-procedure) (pgp util)) ;; ── Error codes (mirror src/error.rs) ─────────────────────────────────── @@ -73,34 +77,90 @@ (define ext (if macos? ".dylib" ".so")) - (define here (or (getenv "JPGP_DIR") ".")) - - ;; JPGP_NATIVE_LIB, if set, is tried first — used by the standalone - ;; binary to point at a sibling-installed dylib. - (define *jpgp-lib-paths* - (let ([base - (list - (string-append here "/pgp-native/target/release/libjpgp_native" ext) - (string-append here "/pgp-native/target/debug/libjpgp_native" ext) - (string-append "/usr/local/lib/libjpgp_native" ext) - (string-append "/opt/homebrew/lib/libjpgp_native" ext) - (string-append (or (getenv "HOME") ".") "/.local/lib/libjpgp_native" ext) - (string-append "libjpgp_native" ext))]) + ;; Release binaries link libjpgp_native statically. Dynamic loading exists + ;; only for an explicitly opted-in development interpreter and never falls + ;; back to CWD, HOME, installation guesses, or a bare platform-loader name. + (def (dynamic-native-paths) + (let ([enabled (getenv "JPGP_ALLOW_DYNAMIC_NATIVE")] + [explicit (getenv "JPGP_NATIVE_LIB")] + [repo (getenv "JPGP_DIR")]) (cond - [(getenv "JPGP_NATIVE_LIB") => (lambda (p) (cons p base))] - [else base]))) + [(not (string=? (or enabled "") "1")) + (if (or explicit repo) + (jpgp-error 'jpgp-loader + "dynamic native loading is disabled; use the statically linked binary") + '())] + [(or (= (posix-geteuid) 0) + (not (= (posix-getuid) (posix-geteuid)))) + (jpgp-error 'jpgp-loader + "dynamic native loading is forbidden in privileged processes")] + [explicit + (unless (path-absolute? explicit) + (jpgp-error 'jpgp-loader "JPGP_NATIVE_LIB must be absolute: ~a" explicit)) + (list explicit)] + [repo + (unless (path-absolute? repo) + (jpgp-error 'jpgp-loader "JPGP_DIR must be absolute: ~a" repo)) + (list + (string-append repo "/pgp-native/target/release/libjpgp_native" ext) + (string-append repo "/pgp-native/target/debug/libjpgp_native" ext))] + [else + (jpgp-error 'jpgp-loader + "development loading requires JPGP_NATIVE_LIB or JPGP_DIR")]))) (define *jpgp-loaded?* #f) (define *jpgp-bindings-ready?* #f) + (def c-lstat (foreign-procedure "lstat" (string u8*) int)) + + ;; Dynamic loading is development-only, but still reject replaceable files + ;; and directories. A canonical leaf is insufficient: an attacker who can + ;; write one of its parent directories can swap the file after validation. + (def (trusted-path-component? path expected-type) + (let ([stat-buffer (make-bytevector 256 0)]) + (and (= 0 (c-lstat path stat-buffer)) + (let* ([mode (if macos? + (bytevector-u16-native-ref stat-buffer 4) + (bytevector-u32-native-ref stat-buffer 24))] + [uid (bytevector-u32-native-ref stat-buffer + (if macos? 16 28))] + [kind (bitwise-and mode #o170000)]) + (and (= kind (if (eq? expected-type 'directory) + #o040000 #o100000)) + (or (= uid 0) (= uid (posix-getuid))) + (= 0 (bitwise-and mode #o022))))))) + + (def (trusted-parent-chain? path) + (let loop ([directory (path-directory path)]) + (and (trusted-path-component? directory 'directory) + (let ([parent (path-directory directory)]) + (or (string=? parent directory) + (loop parent)))))) + + (def (trusted-native-path? path) + (and (not (file-symbolic-link? path)) + (let ([canonical (exec-id-realpath-of path)]) + (and (string=? canonical path) + (trusted-path-component? canonical 'regular) + (trusted-parent-chain? canonical))))) + (def (try-load-one! path) + (and (file-exists? path) + (begin + (unless (trusted-native-path? path) + (jpgp-error 'jpgp-loader + "untrusted native library path: ~a" path)) + (guard (e [(condition? e) #f]) + (load-shared-object* path) + #t)))) + + (def (prelinked-abi-valid?) (guard (e [(condition? e) #f]) - (load-shared-object* path) - #t)) + (= ((c-lambda () unsigned-32 "jpgp_abi_version")) 2))) (def (try-load-jpgp!) (or *jpgp-loaded?* - (let loop ([ps *jpgp-lib-paths*]) + (let loop ([ps (dynamic-native-paths)]) (cond [(null? ps) #f] [(try-load-one! (car ps)) @@ -126,7 +186,13 @@ (define c-abi-version #f) (def (ensure-bindings!) - (when (and (try-load-jpgp!) (not *jpgp-bindings-ready?*)) + (when (and (not *jpgp-bindings-ready?*) + (or (prelinked-abi-valid?) + (and (try-load-jpgp!) (prelinked-abi-valid?)))) + (set! c-abi-version + (c-lambda () unsigned-32 "jpgp_abi_version")) + (unless (= (c-abi-version) 2) + (jpgp-error 'jpgp-loader "native ABI mismatch; expected version 2")) (set! c-age-keygen (c-lambda (u8* unsigned-32 u8* u8* unsigned-32 u8*) int "jpgp_age_keygen")) (set! c-age-encrypt @@ -138,11 +204,11 @@ (set! c-pass-decrypt (c-lambda (u8* unsigned-32 string u8* unsigned-32 u8*) int "jpgp_pass_decrypt")) (set! c-ed25519-keygen - (c-lambda (u8* u8*) int "jpgp_ed25519_keygen")) + (c-lambda (u8* unsigned-32 u8* unsigned-32) int "jpgp_ed25519_keygen")) (set! c-ed25519-sign - (c-lambda (u8* u8* unsigned-32 u8*) int "jpgp_ed25519_sign")) + (c-lambda (u8* unsigned-32 u8* unsigned-32 u8* unsigned-32) int "jpgp_ed25519_sign")) (set! c-ed25519-verify - (c-lambda (u8* u8* unsigned-32 u8*) int "jpgp_ed25519_verify")) + (c-lambda (u8* unsigned-32 u8* unsigned-32 u8* unsigned-32) int "jpgp_ed25519_verify")) (set! c-pgp-encrypt (c-lambda (string u8* unsigned-32 u8* unsigned-32 u8*) int "jpgp_pgp_encrypt")) (set! c-pgp-decrypt @@ -153,8 +219,6 @@