Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/bit/moon.mod.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions modules/bit/src/cmd/bit/moon.pkg
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
144 changes: 144 additions & 0 deletions modules/bit/src/cmd/bit/signing_helpers.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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,
Expand All @@ -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" | _ =>
Expand Down
12 changes: 12 additions & 0 deletions modules/bitx_openpgp/moon.mod.json
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 6 additions & 0 deletions modules/bitx_openpgp/src/moon.pkg.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"import": [
"mizchi/experimental_crypto/pgp",
"mizchi/experimental_crypto/ssh"
]
}
130 changes: 130 additions & 0 deletions modules/bitx_openpgp/src/verify.mbt
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions modules/bitx_openpgp/src/verify_ssh_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -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")
}
16 changes: 16 additions & 0 deletions modules/bitx_openpgp/src/verify_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions moon.work
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading