diff --git a/modules/bit/moon.mod.json b/modules/bit/moon.mod.json index e022b9e0..d7f88be5 100644 --- a/modules/bit/moon.mod.json +++ b/modules/bit/moon.mod.json @@ -12,6 +12,8 @@ "bobzhang/toml": "0.1.7", "mizchi/libgit2": "0.1.0", "mizchi/zlib": "0.4.5", + "mizchi/experimental_crypto": "0.0.2", + "mizchi/bitx_openpgp": "0.42.2", "mizchi/bit_apply": "0.42.2", "mizchi/bit_archive": "0.42.2", "mizchi/bit_bootstrap": "0.42.2", diff --git a/modules/bit/src/cmd/bit/moon.pkg b/modules/bit/src/cmd/bit/moon.pkg index b936e784..bcd53e66 100644 --- a/modules/bit/src/cmd/bit/moon.pkg +++ b/modules/bit/src/cmd/bit/moon.pkg @@ -25,6 +25,7 @@ import { "mizchi/bit_io/native" @bitnative, "mizchi/bitx_hub" @hub, "mizchi/bitx_hub/native" @hub_native, + "mizchi/bitx_openpgp" @openpgp, "mizchi/bitx_rebase_ai" @rebase_ai, "mizchi/bit_vfs" @bitfs, "mizchi/bitx_doc" @xdoc, diff --git a/modules/bit/src/cmd/bit/signing_helpers.mbt b/modules/bit/src/cmd/bit/signing_helpers.mbt index a8f836ba..2e7697ca 100644 --- a/modules/bit/src/cmd/bit/signing_helpers.mbt +++ b/modules/bit/src/cmd/bit/signing_helpers.mbt @@ -750,6 +750,122 @@ async fn signing_run_ssh_verifier( false } +///| +/// Read the native OpenPGP verification keyring, if one is configured. The +/// source is the `gpg.openpgpKeyringFile` config key (a file of one or more +/// ASCII-armored public keys), overridable via the `BIT_OPENPGP_KEYRING` +/// environment variable. A flat key name (no inner dot) is used deliberately +/// so that `git config gpg.openpgpKeyringFile ` stores it under `[gpg]` +/// where bit's config reader can find it, rather than as a `[gpg "openpgp"]` +/// subsection. Returns the parsed armored key blocks when configured, or +/// `None` when no native keyring is set — in which case callers fall back to +/// the external gpg program. Raises when a configured file cannot be read. +fn signing_openpgp_native_keyring( + fs : OsFs, + root : String, + git_dir : String, +) -> Array[String]? raise @bitcore.GitError { + let configured = match @sys.get_env_var("BIT_OPENPGP_KEYRING") { + Some(value) if value.trim().to_owned().length() > 0 => Some(value) + _ => bit_config_get(git_dir, "gpg", "openpgpKeyringFile") + } + guard configured is Some(raw) else { return None } + let value = signing_normalize_config_value(raw) + if value.length() == 0 { + return None + } + let path = signing_resolve_config_path(root, value) + let data = fs.read_file(path) catch { + err => + raise @bitcore.GitError::InvalidObject( + "failed to read gpg.openpgpKeyringFile '\{path}': \{err}", + ) + } + Some(@openpgp.split_armored_public_keys(decode_bytes(data))) +} + +///| +/// Verify a detached OpenPGP signature natively (pure MoonBit, no external +/// gpg) and produce the verification result plus a concise gpg-style status +/// line for stderr. Pure and target-independent; the caller is responsible for +/// emitting the message. +fn signing_openpgp_native_result( + payload : Bytes, + signature : String, + keys : Array[String], +) -> (Bool, String) { + let result = @openpgp.verify_detached_armored(payload, signature, keys) + let message = if result.verified { + "bit: Good OpenPGP signature (native verification)" + } else { + match result.error { + Some(detail) => "bit: OpenPGP signature not verified: " + detail + None => "bit: BAD OpenPGP signature (no configured key validates it)" + } + } + (result.verified, message) +} + +///| +/// Whether native (pure-MoonBit) signature verification is opted into, via the +/// `BIT_NATIVE_VERIFY` environment variable or the `gpg.nativeVerify` config +/// key. This routes SSH verification away from the external `ssh-keygen`. +/// (OpenPGP self-activates whenever a native keyring is configured, so it does +/// not require this flag.) +fn signing_native_verify_enabled(git_dir : String) -> Bool { + match @sys.get_env_var("BIT_NATIVE_VERIFY") { + Some(value) => { + let normalized = value.trim().to_owned().to_lower() + if normalized != "" && normalized != "0" && normalized != "false" { + return true + } + } + None => () + } + match bit_config_get(git_dir, "gpg", "nativeVerify") { + Some(value) => parse_bool_value(value).unwrap_or(false) + None => false + } +} + +///| +/// Read the OpenSSH allowed-signers file (`gpg.ssh.allowedSignersFile`) for +/// native SSH verification. Returns its contents, or `None` when the file is +/// not configured or cannot be read (callers then fall back to `ssh-keygen`). +fn signing_ssh_native_allowed_signers( + fs : OsFs, + root : String, + git_dir : String, +) -> String? { + guard signing_allowed_signers_file(root, git_dir) is Some(path) else { + return None + } + let data = fs.read_file(path) catch { _ => return None } + Some(decode_bytes(data)) +} + +///| +/// Verify a detached SSHSIG signature natively (pure MoonBit, no `ssh-keygen`) +/// and produce the result plus a concise status line for stderr. +fn signing_ssh_native_result( + payload : Bytes, + signature : String, + allowed_signers_text : String, +) -> (Bool, String) { + let result = @openpgp.verify_ssh_detached( + payload, signature, allowed_signers_text, + ) + let message = if result.verified { + "bit: Good SSH signature (native verification)" + } else { + match result.error { + Some(detail) => "bit: SSH signature not verified: " + detail + None => "bit: BAD SSH signature (no allowed signer validates it)" + } + } + (result.verified, message) +} + ///| async fn signing_verify_payload( fs : OsFs, @@ -759,6 +875,34 @@ async fn signing_verify_payload( signature : String, ) -> Bool raise @bitcore.GitError { let format = signing_detect_signature_format(git_dir, signature) + // For OpenPGP signatures, prefer native verification when the caller has + // configured a public-key source; otherwise fall back to the gpg program. + if format == "openpgp" { + match signing_openpgp_native_keyring(fs, root, git_dir) { + Some(keys) => { + let (verified, message) = signing_openpgp_native_result( + payload, signature, keys, + ) + signing_emit_stderr_text(message) catch { _ => () } + return verified + } + None => () + } + } + // For SSH signatures, use native verification when opted in (and an + // allowed-signers file is available); otherwise fall back to `ssh-keygen`. + if format == "ssh" && signing_native_verify_enabled(git_dir) { + match signing_ssh_native_allowed_signers(fs, root, git_dir) { + Some(allowed_text) => { + let (verified, message) = signing_ssh_native_result( + payload, signature, allowed_text, + ) + signing_emit_stderr_text(message) catch { _ => () } + return verified + } + None => () + } + } match format { "ssh" => signing_run_ssh_verifier(fs, root, git_dir, payload, signature) "x509" | _ => diff --git a/modules/bitx_openpgp/moon.mod.json b/modules/bitx_openpgp/moon.mod.json new file mode 100644 index 00000000..c0c5b4af --- /dev/null +++ b/modules/bitx_openpgp/moon.mod.json @@ -0,0 +1,12 @@ +{ + "name": "mizchi/bitx_openpgp", + "version": "0.42.2", + "deps": { + "mizchi/experimental_crypto": "0.0.2" + }, + "repository": "https://github.com/mizchi/bit-vcs", + "license": "Apache-2.0", + "keywords": ["git", "openpgp", "pgp", "signature", "verify"], + "description": "Native OpenPGP signature verification (extension module for mizchi/bit)", + "source": "src" +} diff --git a/modules/bitx_openpgp/src/moon.pkg.json b/modules/bitx_openpgp/src/moon.pkg.json new file mode 100644 index 00000000..cf776ca3 --- /dev/null +++ b/modules/bitx_openpgp/src/moon.pkg.json @@ -0,0 +1,6 @@ +{ + "import": [ + "mizchi/experimental_crypto/pgp", + "mizchi/experimental_crypto/ssh" + ] +} diff --git a/modules/bitx_openpgp/src/verify.mbt b/modules/bitx_openpgp/src/verify.mbt new file mode 100644 index 00000000..2f637953 --- /dev/null +++ b/modules/bitx_openpgp/src/verify.mbt @@ -0,0 +1,130 @@ +///| Native OpenPGP detached-signature verification. +/// +/// Thin wrapper over `mizchi/experimental_crypto/pgp` that lets `bit` verify +/// OpenPGP signatures (commits, tags) without shelling out to `gpg`. The +/// caller supplies the signed payload, the ASCII-armored detached signature, +/// and one or more ASCII-armored public keys to check against. + +///| +/// Outcome of an OpenPGP verification attempt. +pub struct VerifyResult { + /// True when at least one supplied public key validates the signature. + verified : Bool + /// Set when no key validated and a parse/verify error was encountered for + /// every candidate (best-effort diagnostic; `None` for a clean "bad + /// signature" where the keys parsed but none matched). + error : String? +} + +///| +/// Verify a detached, ASCII-armored OpenPGP signature over `payload` against a +/// set of ASCII-armored public keys. Returns `verified = true` as soon as any +/// key validates the signature. Keys that fail to parse, or signature schemes +/// the underlying library does not support, are skipped rather than aborting +/// the whole check, so a single bad key in the file cannot mask a good one. +pub fn verify_detached_armored( + payload : Bytes, + signature_armor : String, + public_keys_armor : Array[String], +) -> VerifyResult { + let mut last_error : String? = None + for key_armor in public_keys_armor { + let packet = try { + @pgp.parse_pubkey_armor(key_armor) + } catch { + _ => { + last_error = Some("failed to parse OpenPGP public key") + continue + } + } + let ok = try { + @pgp.verify_armor(signature_armor, payload, packet.key) + } catch { + _ => { + last_error = Some("unsupported or malformed OpenPGP signature") + continue + } + } + if ok { + return { verified: true, error: None } + } + } + { verified: false, error: last_error } +} + +///| +/// Verify a detached SSHSIG (`-----BEGIN SSH SIGNATURE-----`) signature over +/// `payload` against an OpenSSH allowed-signers file (the contents of +/// `gpg.ssh.allowedSignersFile`). Git always signs with the `git` namespace, +/// so that is what is enforced here. Returns `verified = true` as soon as any +/// principal listed in the allowed-signers file validates the signature — +/// mirroring `ssh-keygen -Y find-principals` followed by `-Y verify`. +pub fn verify_ssh_detached( + payload : Bytes, + signature_armor : String, + allowed_signers_text : String, +) -> VerifyResult { + let signers = try { + @ssh.parse_allowed_signers(allowed_signers_text) + } catch { + _ => + return { verified: false, error: Some("failed to parse allowed signers") } + } + let mut last_error : String? = None + for signer in signers { + for principal in signer.principals { + let ok = try { + @ssh.verify_with_allowed_signers( + allowed_signers_text, + principal, + signature_armor, + payload, + sig_namespace="git", + ) + } catch { + _ => { + last_error = Some("unsupported or malformed SSH signature") + continue + } + } + if ok { + return { verified: true, error: None } + } + } + } + { verified: false, error: last_error } +} + +///| +/// Split a blob that may contain several concatenated ASCII-armored public key +/// blocks (e.g. a keyring file exported with `gpg --armor --export`) into the +/// individual armored blocks. Whitespace/comments between blocks are ignored. +pub fn split_armored_public_keys(text : String) -> Array[String] { + let begin = "-----BEGIN PGP PUBLIC KEY BLOCK-----" + let end = "-----END PGP PUBLIC KEY BLOCK-----" + let blocks : Array[String] = [] + let mut remaining = text + while true { + let start = match remaining.find(begin) { + Some(i) => i + None => break + } + let from_begin = String::unsafe_substring( + remaining, + start=start, + end=remaining.length(), + ) + let end_off = match from_begin.find(end) { + Some(i) => i + None => break + } + let block_end = end_off + end.length() + blocks.push(String::unsafe_substring(from_begin, start=0, end=block_end)) + remaining = String::unsafe_substring( + from_begin, + start=block_end, + end=from_begin.length(), + ) + } + blocks +} diff --git a/modules/bitx_openpgp/src/verify_ssh_wbtest.mbt b/modules/bitx_openpgp/src/verify_ssh_wbtest.mbt new file mode 100644 index 00000000..46680698 --- /dev/null +++ b/modules/bitx_openpgp/src/verify_ssh_wbtest.mbt @@ -0,0 +1,25 @@ +///| Whitebox test for native SSH (SSHSIG) verification, exercised against a +///| real `ssh-keygen -Y sign -n git` signature (fixture from OpenSSH). +test "verify real ssh-keygen ed25519 git signature" { + let msg = "hello git signing" + let payload : Bytes = Bytes::makei(msg.length(), fn(i) { + msg.get_char(i).unwrap().to_int().to_byte() + }) + let sig_armor = + #|-----BEGIN SSH SIGNATURE----- + #|U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg87IrUjpWCUS3yv0YDR71KNHGm9 + #|9iwaqoezx5rJemVxUAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 + #|AAAAQGjEo2XckzSHCF3KQjzquSFX31V7/93S5QcbtsKB8Kq+fiFubv+cUCdNRsrvJJZ1eW + #|USS98VvY3CEN8iz/c7vAE= + #|-----END SSH SIGNATURE----- + let allowed_signers = "mizchi@misc-test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPOyK1I6VglEt8r9GA0e9SjRxpvfYsGqqHs8eayXplcV" + let good = verify_ssh_detached(payload, sig_armor, allowed_signers) + inspect(good.verified, content="true") + // A tampered payload must not verify. + let bad_payload : Bytes = Bytes::makei(3, fn(_i) { b'\x00' }) + let tampered = verify_ssh_detached(bad_payload, sig_armor, allowed_signers) + inspect(tampered.verified, content="false") + // An empty allowed-signers file -> no principal validates it. + let unknown = verify_ssh_detached(payload, sig_armor, "") + inspect(unknown.verified, content="false") +} diff --git a/modules/bitx_openpgp/src/verify_wbtest.mbt b/modules/bitx_openpgp/src/verify_wbtest.mbt new file mode 100644 index 00000000..d8a21f62 --- /dev/null +++ b/modules/bitx_openpgp/src/verify_wbtest.mbt @@ -0,0 +1,16 @@ +///| Whitebox tests for native OpenPGP verification, exercised against a +///| real signature produced by GnuPG (ed25519, detached, ASCII-armored). +test "verify real gpg ed25519 detached signature" { + let payload : Bytes = Bytes::from_array([b'\x74', b'\x72', b'\x65', b'\x65', b'\x20', b'\x34', b'\x62', b'\x38', b'\x32', b'\x35', b'\x64', b'\x63', b'\x36', b'\x34', b'\x32', b'\x63', b'\x62', b'\x36', b'\x65', b'\x62', b'\x39', b'\x61', b'\x30', b'\x36', b'\x30', b'\x65', b'\x35', b'\x34', b'\x62', b'\x66', b'\x38', b'\x64', b'\x36', b'\x39', b'\x32', b'\x38', b'\x38', b'\x66', b'\x62', b'\x65', b'\x65', b'\x34', b'\x39', b'\x30', b'\x34', b'\x0a', b'\x61', b'\x75', b'\x74', b'\x68', b'\x6f', b'\x72', b'\x20', b'\x41', b'\x20', b'\x3c', b'\x61', b'\x40', b'\x65', b'\x3e', b'\x20', b'\x31', b'\x37', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x20', b'\x2b', b'\x30', b'\x30', b'\x30', b'\x30', b'\x0a', b'\x63', b'\x6f', b'\x6d', b'\x6d', b'\x69', b'\x74', b'\x74', b'\x65', b'\x72', b'\x20', b'\x41', b'\x20', b'\x3c', b'\x61', b'\x40', b'\x65', b'\x3e', b'\x20', b'\x31', b'\x37', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x30', b'\x20', b'\x2b', b'\x30', b'\x30', b'\x30', b'\x30', b'\x0a', b'\x0a', b'\x68', b'\x65', b'\x6c', b'\x6c', b'\x6f', b'\x20', b'\x73', b'\x69', b'\x67', b'\x6e', b'\x65', b'\x64', b'\x20', b'\x63', b'\x6f', b'\x6d', b'\x6d', b'\x69', b'\x74', b'\x0a']) + let sig_armor = "-----BEGIN PGP SIGNATURE-----\n\niHUEABYKAB0WIQRutwAix16c8psOpLi58o1L69KPJQUCaj60XgAKCRC58o1L69KP\nJcDsAQDg8oJOROgli9EdT8tcYgNWlWm36nUVAsZX8rXmJQyEJQD/XwFt5u3wHPBU\nZkvflGecPDTlu48ZE6969WPhQ77gBgg=\n=ESFu\n-----END PGP SIGNATURE-----\n" + let keyring = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmDMEaj60XhYJKwYBBAHaRw8BAQdAyWNeGvursagoOmzp4FmwmkmVTvYZQgHcsb29\nMTIUFHm0HlRlc3QgU2lnbmVyIDx0ZXN0QGV4YW1wbGUuY29tPoiTBBMWCgA7FiEE\nbrcAIsdenPKbDqS4ufKNS+vSjyUFAmo+tF4CGwMFCwkIBwICIgIGFQoJCAsCBBYC\nAwECHgcCF4AACgkQufKNS+vSjyWEAwD/T7d/NUMv6JMD31Ht09mQfWeVLDA2xLJ0\n5pZbQUzeMRsBAJbt+Eu1yCH1qjQG3AEk9Jr8+oXfZ8kSiAyZXxQdywkJ\n=beqv\n-----END PGP PUBLIC KEY BLOCK-----\n" + let keys = split_armored_public_keys(keyring) + inspect(keys.length(), content="1") + let good = verify_detached_armored(payload, sig_armor, keys) + inspect(good.verified, content="true") + let bad = Bytes::from_array([b'\x00', b'\x01', b'\x02']) + let tampered = verify_detached_armored(bad, sig_armor, keys) + inspect(tampered.verified, content="false") + let none_keys = verify_detached_armored(payload, sig_armor, []) + inspect(none_keys.verified, content="false") +} diff --git a/moon.work b/moon.work index 9c519764..4b94cb76 100644 --- a/moon.work +++ b/moon.work @@ -35,6 +35,7 @@ members = [ "./modules/bitx_doc", "./modules/bitx_hq", "./modules/bitx_hub", + "./modules/bitx_openpgp", "./modules/bitx_kv", "./modules/bitx_rebase_ai", "./modules/bitx_subdir",