diff --git a/.github/scripts/test_vendor_sntrup.py b/.github/scripts/test_vendor_sntrup.py new file mode 100644 index 00000000..0aeee10d --- /dev/null +++ b/.github/scripts/test_vendor_sntrup.py @@ -0,0 +1,102 @@ +"""Offline regression tests for the upstream/extraction verification gate.""" + +from pathlib import Path +import tempfile +import unittest + +import vendor_sntrup as vendor + + +class VerificationTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) / "java" + self.license = Path(self.temp.name) / "LICENSE.md" + self.name = vendor.NTRU + "Example.java" + self.originals = {self.name: b"original upstream bytes", vendor.LICENSE_ENTRY: b"license"} + self.generated = {self.name: b"adapted Java bytes", vendor.LICENSE_ENTRY: b"license"} + self.manifest = {"version": "1.86", "reviewedVersion": "1.86", "files": { + name: {"originalSha256": vendor.sha256(data), + "extractedSha256": vendor.sha256(self.generated[name])} + for name, data in self.originals.items() + }} + for name, data in self.generated.items(): + path = vendor.destination(name, self.output, self.license) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + def verify(self): + return vendor.verify(self.manifest, self.originals, self.generated, self.output, self.license) + + def test_unchanged_upstream_release_passes(self): + self.manifest["version"] = "1.87" + self.assertEqual([], self.verify()) + + def test_upstream_change_fails_even_when_extracted_output_is_unchanged(self): + self.originals[self.name] += b" upstream-only comment or helper change" + self.assertTrue(any("UPSTREAM CHANGED" in e for e in self.verify())) + + def test_adaptation_change_fails_even_when_upstream_is_unchanged(self): + self.generated[self.name] += b" generator changed" + errors = self.verify() + self.assertTrue(any("EXTRACTION CHANGED" in e for e in errors)) + self.assertTrue(any("VENDORED FILE DIFFERS" in e for e in errors)) + + def test_local_edit_is_detected(self): + (self.output / "Example.java").write_bytes(b"local drift") + self.assertTrue(any("VENDORED FILE DIFFERS" in e for e in self.verify())) + + def test_missing_local_file_is_detected(self): + (self.output / "Example.java").unlink() + self.assertTrue(any("VENDORED FILE DIFFERS" in e for e in self.verify())) + + def test_license_change_is_detected(self): + self.originals[vendor.LICENSE_ENTRY] = b"new upstream license" + self.assertTrue(any("UPSTREAM CHANGED: META-INF/LICENSE.md" in e for e in self.verify())) + + def test_unexpected_source_file_is_detected(self): + (self.output / "Extra.java").write_bytes(b"untracked vendor code") + self.assertTrue(any("Unexpected vendored" in e for e in self.verify())) + + def test_manifest_inventory_change_is_detected(self): + self.manifest["files"]["removed.java"] = {} + self.assertTrue(any("inventory" in e for e in self.verify())) + + def test_version_cannot_change_download_host_or_path(self): + with self.assertRaises(ValueError): + vendor.read_archive("../../unexpected") + + +class ExtractionTest(unittest.TestCase): + def test_transitive_math_helpers_are_retained_and_unrelated_aes_is_removed(self): + sources = { + vendor.NTRU + "Caller.java": "class Caller { int x() { return Utils.outer(); } }", + vendor.NTRU + "Utils.java": """class Utils { + protected static int outer() + { + return inner(); + } + private static int inner() + { + return 7; + } + private static void unusedAES() + { + new AESEngine(); + } + protected static byte[] getHashWithPrefix(byte[] prefix, byte[] hashInput) + { + return null; + } +} +""", + } + result = vendor.client_utils(sources) + self.assertIn("return inner()", result) + self.assertIn("return 7", result) + self.assertNotIn("AES", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/vendor_sntrup.py b/.github/scripts/vendor_sntrup.py new file mode 100644 index 00000000..e14f48a7 --- /dev/null +++ b/.github/scripts/vendor_sntrup.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Reproduce the client-only BC extraction and check upstream/local SHA-256 hashes.""" + +import argparse +import hashlib +import io +import json +from pathlib import Path +import re +import sys +import urllib.request +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "third_party/sntrup/upstream.json" +OUTPUT = ROOT / "sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup" +LICENSE = ROOT / "sshlib/src/main/resources/META-INF/LICENSE-sntrup.md" +PACKAGE = "org.connectbot.sshlib.crypto.sntrup" +PREFIX = "org/bouncycastle/" +NTRU = PREFIX + "pqc/crypto/ntruprime/" +CORE = [ + "SNTRUPrimeKEMExtractor", "SNTRUPrimeKeyGenerationParameters", + "SNTRUPrimeKeyPairGenerator", "SNTRUPrimeKeyParameters", "SNTRUPrimeParameters", + "SNTRUPrimePrivateKeyParameters", "SNTRUPrimePublicKeyParameters", "Utils", +] +SUPPORT = [ + "crypto/AsymmetricCipherKeyPair", "crypto/AsymmetricCipherKeyPairGenerator", + "crypto/KeyGenerationParameters", "crypto/params/AsymmetricKeyParameter", + "pqc/crypto/KEMParameters", "crypto/EncapsulatedSecretExtractor", "crypto/CipherParameters", +] +SOURCE_FILES = [NTRU + n + ".java" for n in CORE] + [PREFIX + n + ".java" for n in SUPPORT] +LICENSE_ENTRY = "META-INF/LICENSE.md" +HEADER = """/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +""" + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def read_archive(version, archive=None): + if archive: + return Path(archive).read_bytes() + if not re.fullmatch(r"\d+(?:\.\d+)+", version): + raise ValueError("Expected a numeric Maven release version") + url = ("https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/" + f"{version}/bcprov-jdk18on-{version}-sources.jar") + with urllib.request.urlopen(url, timeout=60) as response: + return response.read() + + +def client_utils(sources): + """Keep the transitive method closure used by key generation/decapsulation.""" + source = sources[NTRU + "Utils.java"] + methods = {} + pattern = r"^ (?:(?:protected|private|public) )?static [^\n]+?\b(\w+)\([^\n]*\)\s*\n \{" + for match in re.finditer(pattern, source, re.M): + if match[1] in methods: + raise ValueError(f"Upstream Utils now overloads {match[1]}; review the extraction") + start = source.index("{", match.start()) + depth, end = 1, start + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + methods[match[1]] = source[match.start():end] + "\n" + roots = set(re.findall(r"Utils\.(\w+)\(", "\n".join( + s for n, s in sources.items() if not n.endswith("/Utils.java")))) + needed = set() + + def visit(name): + if name in needed: + return + needed.add(name) + for call in re.findall(r"\b(\w+)\(", methods[name]): + if call in methods: + visit(call) + + for name in roots: + visit(name) + # Spell out Java's compound-assignment narrowing without changing the arithmetic. + # Keep the casts on the shifted operands too, matching upstream for every byte value. + if "getEncodedSmallPolynomial" in needed: + for shift in (2, 4, 6): + original = f"x += (byte)(sp[spIndex++] + 1) << {shift};" + explicit = f"x = (byte)(x + ((byte)(sp[spIndex++] + 1) << {shift}));" + body = methods["getEncodedSmallPolynomial"] + if body.count(original) != 1: + raise ValueError("Upstream small-polynomial encoding changed; review the narrowing casts") + methods["getEncodedSmallPolynomial"] = body.replace(original, explicit) + # SHA-512 is mandatory on our Java/Android targets. Keep arithmetic unchanged. + methods["getHashWithPrefix"] = """ protected static byte[] getHashWithPrefix(byte[] prefix, byte[] hashInput) + { + try + { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-512"); + digest.update(prefix); + return digest.digest(hashInput); + } + catch (java.security.NoSuchAlgorithmException e) + { + throw new IllegalStateException(e); + } + } +""" + return ("package org.bouncycastle.pqc.crypto.ntruprime;\n\n" + "import java.security.SecureRandom;\n\nclass Utils\n{\n" + + "\n".join(body for name, body in methods.items() if name in needed) + "}\n") + + +def extract(originals): + sources = {n: data.decode("utf-8") for n, data in originals.items() if n.endswith(".java")} + sources[NTRU + "Utils.java"] = client_utils(sources) + generated = {} + for name, source in sources.items(): + source = re.sub(r"package org\.bouncycastle[^;]*;", f"package {PACKAGE};", source) + source = re.sub(r"import org\.bouncycastle[^;]*;\n", "", source) + # Keep all Java types package-private: only the internal Kotlin adapter uses them. + source = re.sub(r"^public (class|interface) ", r"\1 ", source, flags=re.M) + source = source.replace("CryptoServicesRegistrar.getSecureRandom(random)", + "(random != null ? random : new SecureRandom())") + source = source.replace("CryptoServicesRegistrar.getSecureRandom()", "new SecureRandom()") + source = re.sub(r"Arrays.clone\((\w+)\)", r"\1.clone()", source) + source = source.replace("Arrays.copyOfRange(", "java.util.Arrays.copyOfRange(") + source = source.replace("Arrays.constantTimeAreEqual(", "java.security.MessageDigest.isEqual(") + if name.endswith("/SNTRUPrimePrivateKeyParameters.java"): + source = source.rstrip()[:-1] + """ // Local addition: erase retained private material after KEX or on failure. + public void clear() + { + java.util.Arrays.fill(f, (byte)0); + java.util.Arrays.fill(ginv, (byte)0); + java.util.Arrays.fill(pk, (byte)0); + java.util.Arrays.fill(rho, (byte)0); + java.util.Arrays.fill(hash, (byte)0); + } +} +""" + if "CryptoServicesRegistrar" in source or "org.bouncycastle" in source: + raise ValueError(f"Unadapted BC dependency in {name}") + source = "\n".join(line.rstrip() for line in source.splitlines()).rstrip() + "\n" + generated[name] = (HEADER + source).encode("utf-8") + generated[LICENSE_ENTRY] = originals[LICENSE_ENTRY] + return generated + + +def destination(name, output=OUTPUT, license_file=LICENSE): + return license_file if name == LICENSE_ENTRY else output / name.rsplit("/", 1)[1] + + +def verify(manifest, originals, generated, output=OUTPUT, license_file=LICENSE): + errors = [] + if set(manifest["files"]) != set(originals): + errors.append("Upstream file inventory differs from the recorded manifest") + for name, original in originals.items(): + recorded = manifest["files"].get(name, {}) + if recorded.get("originalSha256") != sha256(original): + errors.append(f"UPSTREAM CHANGED: {name}\n recorded: {recorded.get('originalSha256')}\n released: {sha256(original)}") + expected = generated[name] + if recorded.get("extractedSha256") != sha256(expected): + errors.append(f"EXTRACTION CHANGED: {name}\n recorded: {recorded.get('extractedSha256')}\n regenerated: {sha256(expected)}") + path = destination(name, output, license_file) + if not path.exists() or path.read_bytes() != expected: + errors.append(f"VENDORED FILE DIFFERS: {path}") + unexpected = {p.name for p in output.glob("*.java")} - { + n.rsplit("/", 1)[1] for n in generated if n.endswith(".java")} + if unexpected: + errors.append(f"Unexpected vendored Java files: {sorted(unexpected)}") + return errors + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--check", action="store_true", help="verify without modifying files (default)") + mode.add_argument("--update", action="store_true", help="regenerate sources and accept hashes for review") + parser.add_argument("--archive", type=Path, help="use a local BC sources JAR instead of downloading") + args = parser.parse_args() + manifest = json.loads(MANIFEST.read_text()) + if manifest["artifact"] != "org.bouncycastle:bcprov-jdk18on": + raise ValueError("Unexpected upstream artifact") + with zipfile.ZipFile(io.BytesIO(read_archive(manifest["version"], args.archive))) as archive: + originals = {name: archive.read(name) for name in SOURCE_FILES + [LICENSE_ENTRY]} + generated = extract(originals) + if args.update: + for name, data in generated.items(): + path = destination(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + manifest["reviewedVersion"] = manifest["version"] + manifest["files"] = {name: {"originalSha256": sha256(data), "extractedSha256": sha256(generated[name])} + for name, data in originals.items()} + MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n") + print("Regenerated SNTRUP sources and hashes. Review all changes before committing.") + return 0 + errors = verify(manifest, originals, generated) + if errors: + print("\n".join(errors), file=sys.stderr) + print("Review upstream against reviewedVersion, then run --update and the crypto tests.", file=sys.stderr) + return 1 + print(f"BC {manifest['version']}: all {len(originals)} upstream and extracted SHA-256 hashes match.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError, zipfile.BadZipFile) as error: + sys.exit(f"SNTRUP source verification failed: {error}") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a99f9e1e..5360f036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,22 @@ permissions: contents: read jobs: + vendored-sources: + name: Vendored SNTRUP source provenance + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test source verification + run: python3 -m unittest discover -s .github/scripts -p 'test_vendor_sntrup.py' -v + + - name: Verify published upstream sources and extraction + run: python3 .github/scripts/vendor_sntrup.py --check + workflow-tests: name: Workflow scripts runs-on: ubuntu-latest @@ -34,12 +50,20 @@ jobs: build: name: Build and test + needs: vendored-sources + if: ${{ !cancelled() }} runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: java: [17, 21, 25] steps: + - name: Require reviewed vendored sources + if: needs.vendored-sources.result != 'success' + run: | + echo 'Vendored SNTRUP provenance verification must pass before building or publishing.' + exit 1 + - name: Checkout (with history) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/README.md b/README.md index dec15f78..6dd638d9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ The library supports a wide range of post-quantum and other modern SSH algorithm - **Authentication**: `publickey` (including FIDO2/SK), `password`, `keyboard-interactive` - **Host Keys**: ML-DSA hybrid, Ed25519, Ed448, ECDSA, RSA (SHA-2) -- **Key Exchange**: ML-KEM hybrid, Curve25519, ECDH, DH groups 14/16/18, +- **Key Exchange**: ML-KEM hybrid, SNTRUP761 hybrid, Curve25519, ECDH, DH groups 14/16/18, DH group-exchange - **Encryption**: ChaCha20-Poly1305, AES-GCM, AES-CTR - **MACs**: HMAC-SHA2 (including ETM variants) diff --git a/docs/ALGORITHMS.md b/docs/ALGORITHMS.md index d934c290..83be2766 100644 --- a/docs/ALGORITHMS.md +++ b/docs/ALGORITHMS.md @@ -29,6 +29,7 @@ base-key algorithm is used. ## Host Keys +- `ssh-mldsa87-p384` ([SSH composite signatures draft](https://datatracker.ietf.org/doc/html/draft-ietf-sshm-composite-sigs-00)) — requires platform support - `ssh-mldsa44-ed25519` ([SSH composite signatures draft](https://datatracker.ietf.org/doc/draft-ietf-sshm-composite-sigs/00/)) — requires platform support - `ssh-mldsa-44` ([SSH ML-DSA draft](https://datatracker.ietf.org/doc/draft-sfluhrer-ssh-mldsa/08/)) — requires platform support, opt-in - `ssh-mldsa-65` ([SSH ML-DSA draft](https://datatracker.ietf.org/doc/draft-sfluhrer-ssh-mldsa/08/)) — requires platform support, opt-in @@ -44,9 +45,12 @@ base-key algorithm is used. ML-DSA requires a capable installed JCA provider. On Android, call Google Play Services' `ProviderInstaller.installIfNeeded` before creating `SshClientConfig` -to use its updated security provider. The composite is preferred automatically +to use its updated security provider. Composite host keys are offered by default when available; pure ML-DSA host keys require an explicit `hostKeyAlgorithms` setting. +For `ssh-mldsa87-p384` private keys, use PEM; OpenSSH private-key format is not +supported. + Pure ML-DSA does not have a defined OpenSSH private-key layout; use PKCS#8 PEM. ML-DSA PEM imports require an embedded public key or an appended `PUBLIC KEY` PEM block. @@ -54,6 +58,12 @@ PEM block. ## Key Exchange - `mlkem768x25519-sha256` ([RFC 10042](https://datatracker.ietf.org/doc/html/rfc10042#section-2.3.3)) +- `mlkem768nistp256-sha256` ([RFC 10042 section 2.3.1](https://www.rfc-editor.org/rfc/rfc10042.html#section-2.3.1)) +- `mlkem1024nistp384-sha384` ([RFC 10042 section 2.3.2](https://www.rfc-editor.org/rfc/rfc10042.html#section-2.3.2)) +- `mlkem768-sha256` ([pure ML-KEM draft](https://datatracker.ietf.org/doc/html/draft-harrison-sshm-mlkem-02)) — **opt-in** +- `mlkem1024-sha384` ([pure ML-KEM draft](https://datatracker.ietf.org/doc/html/draft-harrison-sshm-mlkem-02)) — **opt-in** +- `sntrup761x25519-sha512` ([RFC 9941](https://www.rfc-editor.org/rfc/rfc9941.html)) +- `sntrup761x25519-sha512@openssh.com` (the interoperable OpenSSH alias from RFC 9941) - `curve25519-sha256` ([RFC 8731](https://tools.ietf.org/html/rfc8731)) - `ecdh-sha2-nistp521` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) - `ecdh-sha2-nistp384` ([RFC 5656](https://tools.ietf.org/html/rfc5656#section-4)) @@ -66,9 +76,9 @@ PEM block. - `diffie-hellman-group-exchange-sha1` ([RFC 4419](https://tools.ietf.org/html/rfc4419)) — **legacy opt-in** - `diffie-hellman-group1-sha1` ([RFC 4253](https://tools.ietf.org/html/rfc4253#section-8.1)) — **legacy opt-in** -ML-KEM uses the Java KEM API when an ML-KEM provider is available and falls back -to the bundled Kotlin implementation otherwise. It is available on JVM 17 and -does not require native JEP-496 support. +ML-KEM and SNTRUP761 key exchanges are available on Android and JVM 17 without +installing additional cryptographic providers. Pure ML-KEM methods require +explicit opt-in through `kexAlgorithms`. The default key exchange list also advertises `kex-strict-c-v00@openssh.com` (strict key exchange) and `ext-info-c` (extension negotiation). These are diff --git a/docs/agents/internals.md b/docs/agents/internals.md index 7a8ade4f..7d2d71f0 100644 --- a/docs/agents/internals.md +++ b/docs/agents/internals.md @@ -13,6 +13,21 @@ All supported cryptographic algorithms are declared in enum registries. **Visibi **To add a new algorithm**: add an enum entry in `Algorithms.kt`. `SshConnection` and `SshClientConfig` consume the registries. +## Host Key Updates + +- `HostKeyUpdateStateMachine` accepts one `hostkeys-00@openssh.com` advertisement + after authentication. Unknown, disabled, and unavailable algorithms are skipped. +- The client requests proofs for all supported advertised keys, including ML-DSA + and composite keys. `HostKeyProof.kt` checks the entire ordered reply against the + initial session identifier and each exact key blob before invoking persistence. +- Global request replies use a bounded FIFO shared with remote forwarding. A + cancelled or timed-out request retains its slot until its wire reply arrives. +- Proof processing and `HostKeyVerifier.addKeys` / `removeKeys` run outside the + protocol owner. Updates time out after 30 seconds. Only the authenticated key + is retired when omitted; other previously stored keys are not enumerated. +- Persistence is optional: read-only verifiers, including `KnownHostsVerifier`, + keep the default no-op update hooks. + ## Code Generation Pipeline **Kaitai Struct Compiler**: Generates Java classes from `.ksy` protocol definitions. diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 4c7a724c..b6a1f81d 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -35,9 +35,22 @@ are injected into the Docker build via `withFileFromClasspath`. - **Dropbear + RSA pubkey auth**: Dropbear rejects RSA public key authentication. The `rsa_unencrypted` key is excluded from Dropbear pubkey tests. - **AsyncSSH + legacy algorithms**: AsyncSSH disables legacy algorithms by default. The server's `create_server()` call in `server.py` explicitly enables them (CBC ciphers, DH group1, hmac-sha1, etc.). -- **AsyncSSH + ML-KEM**: AsyncSSH requires the `liboqs` C library for post-quantum kex (`mlkem768x25519-sha256`). This is not installed in the test Docker image, so ML-KEM is only tested against Dropbear. +- **AsyncSSH + ML-KEM**: AsyncSSH requires the `liboqs` C library for post-quantum kex (`mlkem768x25519-sha256`). This is not installed in the test Docker image, so that server does not exercise ML-KEM; Dropbear covers the X25519 hybrid. + +- **ML-KEM/NIST hybrids**: OpenSSH 10.6 supports `mlkem768nistp256-sha256` but not + `mlkem1024nistp384-sha384`. Both are tested through authentication and rekey + against `MlKemTestServer`, which uses independent BC ML-KEM and direct JCA + ECDH. BC remains test-only. The P-256 variant also runs against real OpenSSH. - **Dropbear + custom options**: The `DropbearCompatibilityTest.authenticateWithOptions()` helper spins up a fresh container with custom `OPTIONS` env var (passed to `dropbear` CLI). Used for host key type tests with `-r `. +- **Pure ML-KEM**: Both opt-in variants are tested with independent BC + encapsulation, including authentication, encrypted channels after rekey, + malformed ciphertexts, fixed-width secret encoding, and cleanup. +- **ML-DSA-87/P-384**: Conscrypt provides the production-style composite service + in tests. Its signatures are checked independently with BC ML-DSA-87 and JCA + SHA384withECDSA over the draft's message representative. Schemas cover the + nested SSH EC point and scalar encodings; PEM round trips cover both keys. + ## Test Keys Test key pairs are in `sshlib/src/test/resources/keys/`. Available types: ed25519, diff --git a/protocol/src/main/resources/kaitai/global_request_hostkeys_00_openssh.ksy b/protocol/src/main/resources/kaitai/global_request_hostkeys_00_openssh.ksy index a9a6d50a..61d28feb 100644 --- a/protocol/src/main/resources/kaitai/global_request_hostkeys_00_openssh.ksy +++ b/protocol/src/main/resources/kaitai/global_request_hostkeys_00_openssh.ksy @@ -1,9 +1,11 @@ meta: id: global_request_hostkeys_00_openssh endian: be - imports: [] + imports: + - byte_string doc-ref: openssh-PROTOCOL.txt seq: - id: hostkeys - size-eos: true - doc: Concatenated sequence of all server host keys + type: byte_string + repeat: eos + doc: Server public key blobs, each in an SSH string, including unknown key types. diff --git a/protocol/src/main/resources/kaitai/global_request_hostkeys_prove_00_openssh.ksy b/protocol/src/main/resources/kaitai/global_request_hostkeys_prove_00_openssh.ksy index a08d53d8..11d28bce 100644 --- a/protocol/src/main/resources/kaitai/global_request_hostkeys_prove_00_openssh.ksy +++ b/protocol/src/main/resources/kaitai/global_request_hostkeys_prove_00_openssh.ksy @@ -5,6 +5,7 @@ meta: - byte_string doc-ref: openssh-PROTOCOL.txt seq: -- id: signature +- id: hostkeys type: byte_string - doc: Signature proving possession of private host keys + repeat: eos + doc: Public key blobs for which the client requests proof of private key possession. diff --git a/protocol/src/main/resources/kaitai/global_request_response_hostkeys_prove_00_openssh.ksy b/protocol/src/main/resources/kaitai/global_request_response_hostkeys_prove_00_openssh.ksy new file mode 100644 index 00000000..045e1483 --- /dev/null +++ b/protocol/src/main/resources/kaitai/global_request_response_hostkeys_prove_00_openssh.ksy @@ -0,0 +1,11 @@ +meta: + id: global_request_response_hostkeys_prove_00_openssh + endian: be + imports: + - byte_string +doc-ref: https://www.openssh.com/txt/PROTOCOL +seq: +- id: signatures + type: byte_string + repeat: eos + doc: SSH signature blobs in the same order as the requested host keys. diff --git a/protocol/src/main/resources/kaitai/ssh.ksy b/protocol/src/main/resources/kaitai/ssh.ksy index c63405af..68709dee 100644 --- a/protocol/src/main/resources/kaitai/ssh.ksy +++ b/protocol/src/main/resources/kaitai/ssh.ksy @@ -5,6 +5,7 @@ meta: license: CC0-1.0 imports: - ssh_enums + - ssh_hybrid_kex_blob - channel_open_direct_streamlocal_openssh - channel_open_direct_tcpip - channel_open_forwarded_streamlocal_openssh @@ -32,6 +33,7 @@ meta: - global_request_cancel_tcpip_forward - global_request_hostkeys_00_openssh - global_request_hostkeys_prove_00_openssh + - global_request_response_hostkeys_prove_00_openssh - global_request_response - global_request_response_empty - global_request_response_tcpip_forward @@ -56,6 +58,8 @@ meta: - ssh_ed25519_signature_blob - ssh_ed448_public_key_blob - ssh_ed448_signature_blob + - ssh_mldsa_p384_public_key_blob + - ssh_mldsa_p384_signature_blob - ssh_mldsa_public_key_blob - ssh_mldsa_signature_blob - openssh_mldsa_ed25519_private_key diff --git a/protocol/src/main/resources/kaitai/ssh_hybrid_kex_blob.ksy b/protocol/src/main/resources/kaitai/ssh_hybrid_kex_blob.ksy new file mode 100644 index 00000000..11279ad9 --- /dev/null +++ b/protocol/src/main/resources/kaitai/ssh_hybrid_kex_blob.ksy @@ -0,0 +1,14 @@ +meta: + id: ssh_hybrid_kex_blob + endian: be +doc: Hybrid KEX public key or reply containing fixed-width post-quantum and classical components. +params: +- id: len_pq_data + type: u4 +- id: len_classical_data + type: u4 +seq: +- id: pq_data + size: len_pq_data +- id: classical_data + size: len_classical_data diff --git a/protocol/src/main/resources/kaitai/ssh_mldsa_p384_public_key_blob.ksy b/protocol/src/main/resources/kaitai/ssh_mldsa_p384_public_key_blob.ksy new file mode 100644 index 00000000..b89e794a --- /dev/null +++ b/protocol/src/main/resources/kaitai/ssh_mldsa_p384_public_key_blob.ksy @@ -0,0 +1,15 @@ +meta: + id: ssh_mldsa_p384_public_key_blob + endian: be +doc-ref: https://datatracker.ietf.org/doc/html/draft-ietf-sshm-composite-sigs-00 +seq: +- id: len_key + type: u4 + valid: 2693 +- id: mldsa_key + size: 2592 +- id: len_q + type: u4 + valid: 97 +- id: q + size: len_q diff --git a/protocol/src/main/resources/kaitai/ssh_mldsa_p384_signature_blob.ksy b/protocol/src/main/resources/kaitai/ssh_mldsa_p384_signature_blob.ksy new file mode 100644 index 00000000..44830c92 --- /dev/null +++ b/protocol/src/main/resources/kaitai/ssh_mldsa_p384_signature_blob.ksy @@ -0,0 +1,24 @@ +meta: + id: ssh_mldsa_p384_signature_blob + endian: be + imports: + - mpint +doc-ref: https://datatracker.ietf.org/doc/html/draft-ietf-sshm-composite-sigs-00 +seq: +- id: len_signature + type: u4 + valid: + min: 4637 + max: 4733 +- id: signature + type: composite_signature + size: len_signature +types: + composite_signature: + seq: + - id: mldsa_signature + size: 4627 + - id: r + type: mpint + - id: s + type: mpint diff --git a/protocol/src/main/resources/kaitai/ssh_public_key.ksy b/protocol/src/main/resources/kaitai/ssh_public_key.ksy index 3df9208a..02fbd82f 100644 --- a/protocol/src/main/resources/kaitai/ssh_public_key.ksy +++ b/protocol/src/main/resources/kaitai/ssh_public_key.ksy @@ -2,6 +2,7 @@ meta: id: ssh_public_key endian: be imports: + - ssh_mldsa_p384_public_key_blob - byte_string - ssh_mldsa_public_key_blob - ecdsa_public_key_blob @@ -41,4 +42,5 @@ seq: '"ssh-mldsa-65"': ssh_mldsa_public_key_blob(1952) '"ssh-mldsa-87"': ssh_mldsa_public_key_blob(2592) '"ssh-mldsa44-ed25519"': ssh_mldsa_public_key_blob(1344) + '"ssh-mldsa87-p384"': ssh_mldsa_p384_public_key_blob _: byte_string diff --git a/protocol/src/main/resources/kaitai/ssh_signature.ksy b/protocol/src/main/resources/kaitai/ssh_signature.ksy index 6c8f68e6..7da3315c 100644 --- a/protocol/src/main/resources/kaitai/ssh_signature.ksy +++ b/protocol/src/main/resources/kaitai/ssh_signature.ksy @@ -2,6 +2,7 @@ meta: id: ssh_signature endian: be imports: + - ssh_mldsa_p384_signature_blob - byte_string - ssh_mldsa_signature_blob - ecdsa_signature_blob @@ -43,4 +44,5 @@ seq: '"ssh-mldsa-65"': ssh_mldsa_signature_blob(3309) '"ssh-mldsa-87"': ssh_mldsa_signature_blob(4627) '"ssh-mldsa44-ed25519"': ssh_mldsa_signature_blob(2484) + '"ssh-mldsa87-p384"': ssh_mldsa_p384_signature_blob _: byte_string diff --git a/renovate.json5 b/renovate.json5 index fd4f4bb5..26db38ba 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -7,6 +7,14 @@ ":ignoreModulesAndTests" ], "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": ["/^third_party/sntrup/upstream\\.json$/"], + "matchStrings": ["\"version\":\\s*\"(?[^\"]+)\""], + "depNameTemplate": "org.bouncycastle:bcprov-jdk18on", + "datasourceTemplate": "maven", + "versioningTemplate": "maven" + }, { "customType": "regex", "managerFilePatterns": [ @@ -17,5 +25,15 @@ ], versioningTemplate: "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" } + ], + "packageRules": [ + { + "matchDepNames": ["org.bouncycastle:bcprov-jdk18on"], + "groupName": "Bouncy Castle and vendored SNTRUP sources", + "automerge": false, + "prBodyNotes": [ + "The SNTRUP source check compares released upstream files and the reproducible extraction against recorded SHA-256 hashes. If upstream changed, review it against third_party/sntrup/upstream.json reviewedVersion, run python3 .github/scripts/vendor_sntrup.py --update, review the generated changes, and run the crypto tests. Do not accept new hashes without reviewing the upstream changes." + ] + } ] } diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPair.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPair.java new file mode 100644 index 00000000..8a7a7803 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPair.java @@ -0,0 +1,67 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +/** + * a holding class for public/private parameter pairs. + */ +class AsymmetricCipherKeyPair +{ + private AsymmetricKeyParameter publicParam; + private AsymmetricKeyParameter privateParam; + + /** + * basic constructor. + * + * @param publicParam a public key parameters object. + * @param privateParam the corresponding private key parameters. + */ + public AsymmetricCipherKeyPair( + AsymmetricKeyParameter publicParam, + AsymmetricKeyParameter privateParam) + { + this.publicParam = publicParam; + this.privateParam = privateParam; + } + + /** + * basic constructor. + * + * @param publicParam a public key parameters object. + * @param privateParam the corresponding private key parameters. + * @deprecated use AsymmetricKeyParameter + */ + @Deprecated + public AsymmetricCipherKeyPair( + CipherParameters publicParam, + CipherParameters privateParam) + { + this.publicParam = (AsymmetricKeyParameter)publicParam; + this.privateParam = (AsymmetricKeyParameter)privateParam; + } + + /** + * return the public key parameters. + * + * @return the public key parameters. + */ + public AsymmetricKeyParameter getPublic() + { + return publicParam; + } + + /** + * return the private key parameters. + * + * @return the private key parameters. + */ + public AsymmetricKeyParameter getPrivate() + { + return privateParam; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPairGenerator.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPairGenerator.java new file mode 100644 index 00000000..5c673969 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricCipherKeyPairGenerator.java @@ -0,0 +1,27 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +/** + * interface that a public/private key pair generator should conform to. + */ +interface AsymmetricCipherKeyPairGenerator +{ + /** + * intialise the key pair generator. + * + * @param param the parameters the key pair is to be initialised with. + */ + public void init(KeyGenerationParameters param); + + /** + * return an AsymmetricCipherKeyPair containing the generated keys. + * + * @return an AsymmetricCipherKeyPair containing the generated keys. + */ + public AsymmetricCipherKeyPair generateKeyPair(); +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricKeyParameter.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricKeyParameter.java new file mode 100644 index 00000000..e38a9259 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/AsymmetricKeyParameter.java @@ -0,0 +1,25 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class AsymmetricKeyParameter + implements CipherParameters +{ + boolean privateKey; + + public AsymmetricKeyParameter( + boolean privateKey) + { + this.privateKey = privateKey; + } + + public boolean isPrivate() + { + return privateKey; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/CipherParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/CipherParameters.java new file mode 100644 index 00000000..ee83bc0e --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/CipherParameters.java @@ -0,0 +1,14 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +/** + * all parameter classes implement this. + */ +interface CipherParameters +{ +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/EncapsulatedSecretExtractor.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/EncapsulatedSecretExtractor.java new file mode 100644 index 00000000..f8c41291 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/EncapsulatedSecretExtractor.java @@ -0,0 +1,24 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +interface EncapsulatedSecretExtractor +{ + /** + * Extract the secret based on the recipient private key. + * + * @param encapsulation the encapsulated secret. + */ + byte[] extractSecret(byte[] encapsulation); + + /** + * Return the length in bytes of the encapsulation. + * + * @return length in bytes of an encapsulation for this parameter set. + */ + int getEncapsulationLength(); +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KEMParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KEMParameters.java new file mode 100644 index 00000000..4205d734 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KEMParameters.java @@ -0,0 +1,13 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +interface KEMParameters + extends CipherParameters +{ +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KeyGenerationParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KeyGenerationParameters.java new file mode 100644 index 00000000..fc0f9447 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/KeyGenerationParameters.java @@ -0,0 +1,54 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +import java.security.SecureRandom; + +/** + * The base class for parameters to key generators. + */ +class KeyGenerationParameters +{ + private SecureRandom random; + private int strength; + + /** + * initialise the generator with a source of randomness + * and a strength (in bits). + * + * @param random the random byte source. + * @param strength the size, in bits, of the keys we want to produce. + */ + public KeyGenerationParameters( + SecureRandom random, + int strength) + { + this.random = (random != null ? random : new SecureRandom()); + this.strength = strength; + } + + /** + * return the random source associated with this + * generator. + * + * @return the generators random source. + */ + public SecureRandom getRandom() + { + return random; + } + + /** + * return the bit strength for keys produced by this generator, + * + * @return the strength of the keys this generator produces (in bits). + */ + public int getStrength() + { + return strength; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKEMExtractor.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKEMExtractor.java new file mode 100644 index 00000000..5e4b1876 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKEMExtractor.java @@ -0,0 +1,163 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class SNTRUPrimeKEMExtractor + implements EncapsulatedSecretExtractor +{ + private final SNTRUPrimePrivateKeyParameters privateKey; + + public SNTRUPrimeKEMExtractor(SNTRUPrimePrivateKeyParameters privateKey) + { + if (privateKey == null) + { + throw new NullPointerException("'privateKey' cannot be null"); + } + + this.privateKey = privateKey; + } + + @Override + public byte[] extractSecret(byte[] encapsulation) + { + if (encapsulation.length != getEncapsulationLength()) + { + throw new IllegalArgumentException("encapsulation wrong length"); + } + SNTRUPrimeParameters params = privateKey.getParameters(); + + int p = params.getP(); + int q = params.getQ(); + int w = params.getW(); + int roundedPolynomialBytes = params.getRoundedPolynomialBytes(); + + /* + * Decode(f) + */ + byte[] f = new byte[p]; + Utils.getDecodedSmallPolynomial(f, privateKey.getF(), p); + + /* + * Decode(ginv) + */ + byte[] ginv = new byte[p]; + Utils.getDecodedSmallPolynomial(ginv, privateKey.getGinv(), p); + + /* + * c = Decode(ct) + */ + short[] c = new short[p]; + Utils.getRoundedDecodedPolynomial(c, encapsulation, p, q); + + /* + * Generate 3cf + */ + short[] cf = new short[p]; + Utils.multiplicationInRQ(cf, c, f, p, q); + + short[] cf3 = new short[p]; + Utils.scalarMultiplicationInRQ(cf3, cf, 3, q); + + /* + * Transform 3cf from RQ to R3 + */ + byte[] e = new byte[p]; + Utils.transformRQToR3(e, cf3); + + /* + * ev = e.ginv in R3 + */ + byte[] ev = new byte[p]; + Utils.multiplicationInR3(ev, e, ginv, p); + + /* + * Check if ev in R3 can be lifted to small polynomial with weight w + */ + byte[] r = new byte[p]; + Utils.checkForSmallPolynomial(r, ev, p, w); + + /* + * encR = Encode(r) + */ + + byte[] encR = new byte[(p + 3) / 4]; + Utils.getEncodedSmallPolynomial(encR, r, p); + + /* + * h = Decode(pk) + */ + short[] h = new short[p]; + Utils.getDecodedPolynomial(h, privateKey.getPk(), p, q); + + /* + * cnew = Round(hr) + */ + short[] hr = new short[p]; + Utils.multiplicationInRQ(hr, h, r, p, q); + + short[] cnew = new short[p]; + Utils.roundPolynomial(cnew, hr); + + /* + * C = Encode(cnew) + */ + byte[] C = new byte[roundedPolynomialBytes]; + Utils.getRoundedEncodedPolynomial(C, cnew, p, q); + + /* + * hc = SHA-512(2 | SHA-512(3|encR)[0:32] | cache[0:32]) + */ + byte[] innerHCPrefix = {3}; + byte[] innerHCHash = Utils.getHashWithPrefix(innerHCPrefix, encR); + + byte[] hcInput = new byte[(innerHCHash.length / 2) + privateKey.getHash().length]; + System.arraycopy(innerHCHash, 0, hcInput, 0, innerHCHash.length / 2); + System.arraycopy(privateKey.getHash(), 0, hcInput, innerHCHash.length / 2, privateKey.getHash().length); + + byte[] hcPrefix = {2}; + byte[] hc = Utils.getHashWithPrefix(hcPrefix, hcInput); + + /* + * ct = C | hc[0:32] + */ + byte[] ct = new byte[C.length + (hc.length / 2)]; + System.arraycopy(C, 0, ct, 0, C.length); + System.arraycopy(hc, 0, ct, C.length, hc.length / 2); + + /* + * Match Ciphertext ct with input encapsulation + * Update encR accordingly + */ + int mask = (java.security.MessageDigest.isEqual(encapsulation, ct)) ? 0 : -1; + + /* + * Update encR with Ciphertext diff mask + */ + Utils.updateDiffMask(encR, privateKey.getRho(), mask); + + /* + * ss = SHA-512((mask + 1) | SHA-512(3|encR)[0:32] | ct)[0:32] + */ + byte[] innerSSPrefix = {3}; + byte[] innerSSHash = Utils.getHashWithPrefix(innerSSPrefix, encR); + + byte[] ssInput = new byte[(innerSSHash.length / 2) + ct.length]; + System.arraycopy(innerSSHash, 0, ssInput, 0, innerSSHash.length / 2); + System.arraycopy(ct, 0, ssInput, innerSSHash.length / 2, ct.length); + + byte[] ssPrefix = {(byte)(mask + 1)}; + byte[] ssHash = Utils.getHashWithPrefix(ssPrefix, ssInput); + + return java.util.Arrays.copyOfRange(ssHash, 0, params.getSessionKeySize() / 8); + } + + public int getEncapsulationLength() + { + return privateKey.getParameters().getEncapsulationLength(); + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyGenerationParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyGenerationParameters.java new file mode 100644 index 00000000..e4b65435 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyGenerationParameters.java @@ -0,0 +1,34 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +import java.security.SecureRandom; + + +class SNTRUPrimeKeyGenerationParameters + extends KeyGenerationParameters +{ + private final SNTRUPrimeParameters sntrupParams; + + /** + * initialise the generator with a source of randomness + * and a strength (in bits). + * + * @param random the random byte source. + * @param sntrupParams Streamlined NTRU Prime parameters + */ + public SNTRUPrimeKeyGenerationParameters(SecureRandom random, SNTRUPrimeParameters sntrupParams) + { + super(null != random ? random : new SecureRandom(), 256); + this.sntrupParams = sntrupParams; + } + + public SNTRUPrimeParameters getSntrupParams() + { + return sntrupParams; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyPairGenerator.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyPairGenerator.java new file mode 100644 index 00000000..41dc4d91 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyPairGenerator.java @@ -0,0 +1,94 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class SNTRUPrimeKeyPairGenerator + implements AsymmetricCipherKeyPairGenerator +{ + private SNTRUPrimeKeyGenerationParameters params; + + public SNTRUPrimeKeyGenerationParameters getParams() + { + return params; + } + + /** + * Initialize the Key Pair Generator. + * + * @param param the parameters the key pair is to be initialised with. + */ + public void init(KeyGenerationParameters param) + { + this.params = (SNTRUPrimeKeyGenerationParameters) param; + } + + /** + * return an AsymmetricCipherKeyPair containing the generated keys. + * + * @return an AsymmetricCipherKeyPair containing the generated keys. + */ + public AsymmetricCipherKeyPair generateKeyPair() + { + int p = params.getSntrupParams().getP(); + int q = params.getSntrupParams().getQ(); + int w = params.getSntrupParams().getW(); + + /* + * Generate Random Small Polynomial g from R/3 until it is invertible in R/3 + * Generate Inverse of Random Small Polynomial (1/g) in R/3 + */ + byte[] g = new byte[p]; + byte[] ginv = new byte[p]; + do Utils.getRandomSmallPolynomial(params.getRandom(), g); + while (!Utils.isInvertiblePolynomialInR3(g, ginv, p)); + + /* + * Generate Random Short Polynomial from R/3 with weight w + * Generate Inverse of Random Short Polynomial (1/3f) in R/q + */ + byte[] f = new byte[p]; + Utils.getRandomShortPolynomial(params.getRandom(), f, p, w); + + short[] finv3 = new short[p]; + Utils.getOneThirdInverseInRQ(finv3, f, p, q); + + /* + * Compute h = g/3f in R/q + */ + short[] h = new short[p]; + Utils.multiplicationInRQ(h, finv3, g, p, q); + + /* + * Public Key = Encode(h) + */ + byte[] pk = new byte[params.getSntrupParams().getPublicKeyBytes()]; + Utils.getEncodedPolynomial(pk, h, p, q); + + SNTRUPrimePublicKeyParameters publicKey = new SNTRUPrimePublicKeyParameters(params.getSntrupParams(), pk); + + /* + * Private Key = Encode(f) | Encode(1/g) | pk | Random rho | SHA-512(4|pk) + */ + byte[] encF = new byte[(p + 3) / 4]; + Utils.getEncodedSmallPolynomial(encF, f, p); + + byte[] encGinv = new byte[(p + 3) / 4]; + Utils.getEncodedSmallPolynomial(encGinv, ginv, p); + + byte[] rho = new byte[(p + 3) / 4]; + params.getRandom().nextBytes(rho); + + byte[] prefix = {4}; + byte[] hash = Utils.getHashWithPrefix(prefix, pk); + + SNTRUPrimePrivateKeyParameters privateKey = new SNTRUPrimePrivateKeyParameters(params.getSntrupParams(), encF, encGinv, + pk, rho, java.util.Arrays.copyOfRange(hash, 0, hash.length / 2)); + + return new AsymmetricCipherKeyPair(publicKey, privateKey); + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyParameters.java new file mode 100644 index 00000000..dafac518 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeKeyParameters.java @@ -0,0 +1,25 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class SNTRUPrimeKeyParameters + extends AsymmetricKeyParameter +{ + private final SNTRUPrimeParameters params; + + public SNTRUPrimeKeyParameters(boolean privateKey, SNTRUPrimeParameters params) + { + super(privateKey); + this.params = params; + } + + public SNTRUPrimeParameters getParameters() + { + return params; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeParameters.java new file mode 100644 index 00000000..3ef4118d --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimeParameters.java @@ -0,0 +1,118 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +/** + * StreamLined NTRU Prime Parameter Specs + */ +class SNTRUPrimeParameters + implements KEMParameters +{ + public static final SNTRUPrimeParameters sntrup653 = new SNTRUPrimeParameters("sntrup653", 653, 4621, 288, + 994, 865, 994, 1518, 32); + public static final SNTRUPrimeParameters sntrup761 = new SNTRUPrimeParameters("sntrup761", 761, 4591, 286, + 1158, 1007, 1158, 1763, 32); + public static final SNTRUPrimeParameters sntrup857 = new SNTRUPrimeParameters("sntrup857", 857, 5167, 322, + 1322, 1152, 1322, 1999, 32); + public static final SNTRUPrimeParameters sntrup953 = new SNTRUPrimeParameters("sntrup953", 953, 6343, 396, + 1505, 1317, 1505, 2254, 32); + public static final SNTRUPrimeParameters sntrup1013 = new SNTRUPrimeParameters("sntrup1013", 1013, 7177, 448, + 1623, 1423, 1623, 2417, 32); + public static final SNTRUPrimeParameters sntrup1277 = new SNTRUPrimeParameters("sntrup1277", 1277, 7879, 492, + 2067, 1815, 2067, 3059, 32); + + private final String name; + private final int p; + private final int q; + private final int w; + private final int rqPolynomialBytes; + private final int roundedPolynomialBytes; + private final int publicKeyBytes; + private final int privateKeyBytes; + private final int sharedKeyBytes; + + /** + * Construct Parameter set and initialize engine + * + * @param name name of parameter spec + * @param p p is prime and degree of ring polynomial + * @param q q is prime and used for irreducible ring polynomial + * @param w w is a positive integer less than p + * @param rqPolynomialBytes rqPolynomialBytes is bytes taken to represent the ring polynomial + * @param roundedPolynomialBytes roundedPolynomialBytes is bytes taken to represent rounded polynomial + * @param publicKeyBytes Public Key byte length + * @param privateKeyBytes Private Key byte length + */ + private SNTRUPrimeParameters(String name, int p, int q, int w, int rqPolynomialBytes, int roundedPolynomialBytes, int publicKeyBytes, int privateKeyBytes, int sharedKeyBytes) + { + this.name = name; + this.p = p; + this.q = q; + this.w = w; + this.rqPolynomialBytes = rqPolynomialBytes; + this.roundedPolynomialBytes = roundedPolynomialBytes; + this.publicKeyBytes = publicKeyBytes; + this.privateKeyBytes = privateKeyBytes; + this.sharedKeyBytes = sharedKeyBytes; + } + + public String getName() + { + return name; + } + + public int getP() + { + return p; + } + + public int getQ() + { + return q; + } + + public int getW() + { + return w; + } + + public int getPublicKeyBytes() + { + return publicKeyBytes; + } + + public int getPrivateKeyBytes() + { + return privateKeyBytes; + } + + public int getRqPolynomialBytes() + { + return rqPolynomialBytes; + } + + public int getRoundedPolynomialBytes() + { + return roundedPolynomialBytes; + } + + public int getSessionKeySize() + { + return sharedKeyBytes * 8; + } + + /** + * Return the size in bytes of an encapsulation produced for this parameter set, so that the + * encapsulating side - which holds only a public key and no extractor - reads it from the same + * place SNTRUPrimeKEMExtractor does. + */ + public int getEncapsulationLength() + { + return roundedPolynomialBytes + 32; + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePrivateKeyParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePrivateKeyParameters.java new file mode 100644 index 00000000..a2a13a2b --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePrivateKeyParameters.java @@ -0,0 +1,74 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class SNTRUPrimePrivateKeyParameters + extends SNTRUPrimeKeyParameters +{ + private final byte[] f; + private final byte[] ginv; + private final byte[] pk; + private final byte[] rho; + private final byte[] hash; + + public SNTRUPrimePrivateKeyParameters(SNTRUPrimeParameters params, byte[] f, byte[] ginv, + byte[] pk, byte[] rho, byte[] hash) + { + super(true, params); + this.f = f.clone(); + this.ginv = ginv.clone(); + this.pk = pk.clone(); + this.rho = rho.clone(); + this.hash = hash.clone(); + } + + public byte[] getF() + { + return f.clone(); + } + + public byte[] getGinv() + { + return ginv.clone(); + } + + public byte[] getPk() + { + return pk.clone(); + } + + public byte[] getRho() + { + return rho.clone(); + } + + public byte[] getHash() + { + return hash.clone(); + } + + public byte[] getEncoded() + { + byte[] key = new byte[getParameters().getPrivateKeyBytes()]; + System.arraycopy(f, 0, key, 0, f.length); + System.arraycopy(ginv, 0, key, f.length, ginv.length); + System.arraycopy(pk, 0, key, f.length + ginv.length, pk.length); + System.arraycopy(rho, 0, key, f.length + ginv.length + pk.length, rho.length); + System.arraycopy(hash, 0, key, f.length + ginv.length + pk.length + rho.length, hash.length); + return key; + } + // Local addition: erase retained private material after KEX or on failure. + public void clear() + { + java.util.Arrays.fill(f, (byte)0); + java.util.Arrays.fill(ginv, (byte)0); + java.util.Arrays.fill(pk, (byte)0); + java.util.Arrays.fill(rho, (byte)0); + java.util.Arrays.fill(hash, (byte)0); + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePublicKeyParameters.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePublicKeyParameters.java new file mode 100644 index 00000000..16879fd6 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/SNTRUPrimePublicKeyParameters.java @@ -0,0 +1,36 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + + +class SNTRUPrimePublicKeyParameters + extends SNTRUPrimeKeyParameters +{ + private final byte[] encH; + + public SNTRUPrimePublicKeyParameters(SNTRUPrimeParameters params, byte[] encH) + { + super(false, params); + + if (encH.length != params.getPublicKeyBytes()) + { + throw new IllegalArgumentException("'encH' has invalid length"); + } + + this.encH = encH.clone(); + } + + byte[] getEncH() + { + return encH; + } + + public byte[] getEncoded() + { + return encH.clone(); + } +} diff --git a/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/Utils.java b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/Utils.java new file mode 100644 index 00000000..4255da30 --- /dev/null +++ b/sshlib/src/main/java/org/connectbot/sshlib/crypto/sntrup/Utils.java @@ -0,0 +1,605 @@ +/* + * Derived from Bouncy Castle; see META-INF/LICENSE-sntrup.md. + * Generated by .github/scripts/vendor_sntrup.py. Do not edit directly. + * Upstream provenance and SHA-256 hashes: third_party/sntrup/upstream.json. + */ + +package org.connectbot.sshlib.crypto.sntrup; + +import java.security.SecureRandom; + +class Utils +{ + protected static int getRandomUnsignedInteger(SecureRandom random) + { + byte[] c = new byte[4]; + random.nextBytes(c); + return (bToUnsignedInt(c[0]) + + (bToUnsignedInt(c[1]) << 8) + + (bToUnsignedInt(c[2]) << 16) + + (bToUnsignedInt(c[3]) << 24)); + } + + protected static void getRandomSmallPolynomial(SecureRandom random, byte[] g) + { + for (int i = 0; i < g.length; i++) + g[i] = (byte)((((getRandomUnsignedInteger(random) & 0x3fffffff) * 3) >>> 30) - 1); + } + + protected static int getModFreeze(int x, int n) + { + return getSignedDivMod((x + ((n - 1) / 2)), n)[1] - ((n - 1) / 2); + } + + protected static boolean isInvertiblePolynomialInR3(byte[] g, byte[] ginv, int p) + { + byte[] f = new byte[p + 1]; + byte[] h = new byte[p + 1]; + byte[] r = new byte[p + 1]; + byte[] v = new byte[p + 1]; + int loop, delta, sign, swap, t, i; + + r[0] = 1; + f[0] = 1; + f[p - 1] = -1; + f[p] = -1; + for (i = 0; i < p; i++) + h[p - 1 - i] = g[i]; + + h[p] = 0; + delta = 1; + + for (loop = 0; loop < (2 * p) - 1; loop++) + { + System.arraycopy(v, 0, v, 1, p); + v[0] = 0; + + sign = -h[0] * f[0]; + swap = checkLessThanZero(-delta) & checkNotEqualToZero(h[0]); + delta ^= swap & (delta ^ -delta); + delta += 1; + + for (i = 0; i < p + 1; i++) + { + t = swap & (f[i] ^ h[i]); + f[i] ^= t; + h[i] ^= t; + t = swap & (v[i] ^ r[i]); + v[i] ^= t; + r[i] ^= t; + } + + for (i = 0; i < p + 1; i++) + h[i] = (byte)getModFreeze(h[i] + sign * f[i], 3); + for (i = 0; i < p + 1; i++) + r[i] = (byte)getModFreeze(r[i] + sign * v[i], 3); + + for (i = 0; i < p; i++) + h[i] = h[i+1]; + h[p] = 0; + } + + sign = f[0]; + for (i = 0; i < p; i++) + ginv[i] = (byte)(sign * v[p - 1 - i]); + + return (delta == 0); + } + + protected static void minmax(int[] L, int x, int y) + { + int xi = L[x]; + int yi = L[y]; + int xy = xi ^ yi; + int c = yi - xi; + c ^= xy & (c ^ yi ^ 0x80000000); + c = c >>> 31; + c = -c; + c &= xy; + L[x] = xi ^ c; + L[y] = yi ^ c; + } + + protected static void cryptoSort(int[] L, int p) + { + int top, a, b, i; + + if (p < 2) + return; + + top = 1; + while (top < p - top) + top += top; + + for (a = top; a > 0; a = a >>> 1) + { + for (i = 0; i < p - a; i++) + if ((i & a) == 0) + minmax(L, i, i + a); + for (b = top; b > a; b = b >>> 1) + for (i = 0; i < p - b; i++) + if ((i & a) == 0) + minmax(L, i + a, i + b); + } + } + + protected static void sortGenerateShortPolynomial(byte[] f, int[] L, int p, int w) + { + for (int i = 0; i < w; i++) + L[i] = L[i] & -2; + for (int i = w; i < p; i++) + L[i] = (L[i] & -3) | 1; + cryptoSort(L, p); + for (int i = 0; i < p; i++) + f[i] = (byte)((L[i] & 3) - 1); + } + + protected static void getRandomShortPolynomial(SecureRandom random, byte[] f, int p, int w) + { + int[] L = new int[p]; + for (int i = 0; i < p; i++) + L[i] = getRandomUnsignedInteger(random); + sortGenerateShortPolynomial(f, L, p, w); + } + + protected static int getInverseInRQ(int x, int q) + { + int ai = x; + for (int i = 1; i < q - 2; i++) + ai = getModFreeze(x * ai, q); + return ai; + } + + protected static void getOneThirdInverseInRQ(short[] finv3, byte[] f, int p, int q) + { + short[] h = new short[p + 1]; + short[] g = new short[p + 1]; + short[] r = new short[p + 1]; + short[] v = new short[p + 1]; + int loop, delta, scale, swap, h0, g0, t, i; + + r[0] = (short)getInverseInRQ(3, q); + h[0] = 1; + h[p - 1] = -1; + h[p] = -1; + for (i = 0; i < p; i++) + g[p - 1 - i] = f[i]; + + g[p] = 0; + delta = 1; + + for (loop = 0; loop < (2 * p) - 1; loop++) + { + System.arraycopy(v, 0, v, 1, p); + v[0] = 0; + + swap = checkLessThanZero(-delta) & checkNotEqualToZero(g[0]); + delta ^= swap & (delta ^ -delta); + delta += 1; + + for (i = 0; i < p + 1; i++) + { + t = swap & (h[i] ^ g[i]); + h[i] ^= t; + g[i] ^= t; + t = swap & (v[i] ^ r[i]); + v[i] ^= t; + r[i] ^= t; + } + + h0 = h[0]; + g0 = g[0]; + for (i = 0; i < p + 1; i++) + g[i] = (short)getModFreeze((h0 * g[i]) - (g0 * h[i]), q); + for (i = 0; i < p + 1; i++) + r[i] = (short)getModFreeze((h0 * r[i]) - (g0 * v[i]), q); + + for (i = 0; i < p; i++) + g[i] = g[i+1]; + g[p] = 0; + } + + scale = getInverseInRQ(h[0], q); + for (i = 0; i < p; i++) + finv3[i] = (short)getModFreeze(scale * v[p - 1 - i], q); + } + + protected static void multiplicationInRQ(short[] h, short[] finv3, byte[] g, int p, int q) + { + short[] fg = new short[p + p - 1]; + short result; + int i, j; + + for (i = 0; i < p; i++) + { + result = 0; + for (j = 0; j <= i; j++) + result = (short)getModFreeze(result + (finv3[j] * g[i - j]), q); + fg[i] = result; + } + + for (i = p; i < p + p - 1; i++) + { + result = 0; + for (j = i - p + 1; j < p; j++) + result = (short)getModFreeze(result + (finv3[j] * g[i - j]), q); + fg[i] = result; + } + + for (i = p + p - 2; i >= p; i--) + { + fg[i - p] = (short)getModFreeze(fg[i - p] + fg[i], q); + fg[i - p + 1] = (short)getModFreeze(fg[i - p + 1] + fg[i], q); + } + + for (i = 0; i < p; i++) + h[i] = fg[i]; + } + + private static void encode(byte[] out, short[] R, short[] M, int len, int start) + { + if (len == 1) + { + short r = R[0]; + short m = M[0]; + while (m > 1) + { + out[start++] = (byte)r; + r = (short)(r >>> 8); + m = (short)((m + 255) >>> 8); + } + } + + if (len > 1) + { + short[] R2 = new short[(len + 1) / 2]; + short[] M2 = new short[(len + 1) / 2]; + int i; + + for (i = 0; i < len - 1; i += 2) + { + int m0 = M[i]; + int r = R[i] + (R[i + 1] * m0); + int m = M[i + 1] * m0; + while (m >= 16384) + { + out[start++] = (byte)r; + r = r >>> 8; + m = (m + 255) >>> 8; + } + R2[i / 2] = (short)r; + M2[i / 2] = (short)m; + } + + if (i < len) + { + R2[i / 2] = R[i]; + M2[i / 2] = M[i]; + } + + encode(out, R2, M2,(len + 1) / 2, start); + } + } + + protected static void getEncodedPolynomial(byte[] enc, short[] h, int p, int q) + { + short[] R = new short[p]; + short[] M = new short[p]; + + for (int i = 0; i < p; i++) + R[i] = (short)(h[i] + ((q - 1) / 2)); + for (int i = 0; i < p; i++) + M[i] = (short)q; + + encode(enc, R, M, p, 0); + } + + protected static void getEncodedSmallPolynomial(byte[] encSP, byte[] sp, int p) + { + byte x; + int spIndex = 0; + int encSPIndex = 0; + for (int i = 0; i < p / 4; i++) + { + x = (byte)(sp[spIndex++] + 1); + x = (byte)(x + ((byte)(sp[spIndex++] + 1) << 2)); + x = (byte)(x + ((byte)(sp[spIndex++] + 1) << 4)); + x = (byte)(x + ((byte)(sp[spIndex++] + 1) << 6)); + encSP[encSPIndex++] = x; + } + encSP[encSPIndex] = (byte)(sp[spIndex] + 1); + } + + private static int getUnsignedMod(int x, int n) + { + return getUnsignedDivMod(x, n)[1]; + } + + protected static void roundPolynomial(short[] out, short[] in) + { + for (int i = 0; i < out.length; i++) + out[i] = (short)(in[i] - getModFreeze(in[i], 3)); + } + + protected static void getRoundedEncodedPolynomial(byte[] out, short[] in, int p, int q) + { + short[] R = new short[p]; + short[] M = new short[p]; + + for (int i = 0; i < p; i++) + { + R[i] = (short)(((in[i] + ((q - 1) / 2)) * 10923) >>> 15); + M[i] = (short)((q + 2) / 3); + } + + encode(out, R, M, p, 0); + } + + protected static byte[] getHashWithPrefix(byte[] prefix, byte[] hashInput) + { + try + { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-512"); + digest.update(prefix); + return digest.digest(hashInput); + } + catch (java.security.NoSuchAlgorithmException e) + { + throw new IllegalStateException(e); + } + } + + private static void decode(short[] out, byte[] S, short[] M, int len, int start, int sIndex) + { + if (len == 1) + { + if (M[0] == 1) + out[start] = 0; + else if (M[0] <= 256) + out[start] = (short)getUnsignedMod(bToUnsignedInt(S[sIndex]), M[0]); + else + out[start] = (short)getUnsignedMod(bToUnsignedInt(S[sIndex]) + (S[sIndex + 1] << 8), M[0]); + } + + if (len > 1) + { + short[] R2 = new short[(len + 1) / 2]; + short[] M2 = new short[(len + 1) / 2]; + short[] bottomr = new short[len / 2]; + int[] bottomt = new int[len / 2]; + int i; + + for (i = 0; i < len - 1; i += 2) + { + int m = M[i] * (int)M[i+1]; + if (m > (256 * 16383)) + { + bottomt[i / 2] = 256 * 256; + bottomr[i / 2] = (short)(bToUnsignedInt(S[sIndex]) + (256 * bToUnsignedInt(S[sIndex + 1]))); + sIndex += 2; + M2[i / 2] = (short)((((m + 255) >>> 8) + 255) >>> 8); + } + else if (m >= 16384) + { + bottomt[i / 2] = 256; + bottomr[i / 2] = (short)bToUnsignedInt(S[sIndex]); + sIndex += 1; + M2[i / 2] = (short)((m + 255) >>> 8); + } + else + { + bottomt[i / 2] = 1; + bottomr[i / 2] = 0; + M2[i / 2] = (short)m; + } + } + if (i < len) + M2[i / 2] = M[i]; + + decode(R2, S, M2,(len + 1) / 2, start, sIndex); + + for (i = 0; i < len - 1; i += 2) + { + int r = sToUnsignedInt(bottomr[i / 2]); + r += bottomt[i / 2] * sToUnsignedInt(R2[i / 2]); + int[] r01 = getUnsignedDivMod(r, M[i]); + out[start++] = (short)r01[1]; + out[start++] = (short)getUnsignedMod(r01[0], M[i + 1]); + } + if (i < len) + out[start] = R2[i / 2]; + } + } + + protected static void getDecodedPolynomial(short[] h, byte[] enc, int p, int q) + { + short[] R = new short[p]; + short[] M = new short[p]; + + for (int i = 0; i < p; i++) + M[i] = (short)q; + + decode(R, enc, M, p, 0, 0); + + for (int i = 0; i < p; i++) + h[i] = (short)(R[i] - ((q - 1) / 2)); + } + + protected static void getRoundedDecodedPolynomial(short[] h, byte[] enc, int p, int q) + { + short[] R = new short[p]; + short[] M = new short[p]; + + for (int i = 0; i < p; i++) + M[i] = (short)((q + 2) / 3); + + decode(R, enc, M, p, 0, 0); + + for (int i = 0; i < p; i++) + h[i] = (short)((R[i] * 3) - ((q - 1) / 2)); + } + + protected static void getDecodedSmallPolynomial(byte[] sp, byte[] encSP, int p) + { + byte x; + int spIndex = 0; + int encSPIndex = 0; + + for (int i = 0; i < p / 4; i++) + { + x = encSP[encSPIndex++]; + sp[spIndex++] = (byte)((bToUnsignedInt(x) & 3) - 1); x >>>= 2; + sp[spIndex++] = (byte)((bToUnsignedInt(x) & 3) - 1); x >>>= 2; + sp[spIndex++] = (byte)((bToUnsignedInt(x) & 3) - 1); x >>>= 2; + sp[spIndex++] = (byte)((bToUnsignedInt(x) & 3) - 1); + } + + x = encSP[encSPIndex]; + sp[spIndex] = (byte)((bToUnsignedInt(x) & 3) - 1); + } + + protected static void scalarMultiplicationInRQ(short[] out, short[] in, int scalar, int q) + { + for (int i = 0; i < in.length; i++) + out[i] = (short)getModFreeze(scalar * in[i], q); + } + + protected static void transformRQToR3(byte[] out, short[] in) + { + for (int i = 0; i < in.length; i++) + out[i] = (byte)getModFreeze(in[i], 3); + } + + protected static void multiplicationInR3(byte[] h, byte[] finv3, byte[] g, int p) + { + byte[] fg = new byte[p + p - 1]; + byte result; + int i, j; + + for (i = 0; i < p; i++) + { + result = 0; + for (j = 0; j <= i; j++) + result = (byte) getModFreeze(result + (finv3[j] * g[i - j]), 3); + fg[i] = result; + } + + for (i = p; i < p + p - 1; i++) + { + result = 0; + for (j = i - p + 1; j < p; j++) + result = (byte)getModFreeze(result + (finv3[j] * g[i - j]), 3); + fg[i] = result; + } + + for (i = p + p - 2; i >= p; i--) + { + fg[i - p] = (byte)getModFreeze(fg[i - p] + fg[i], 3); + fg[i - p + 1] = (byte)getModFreeze(fg[i - p + 1] + fg[i], 3); + } + + for (i = 0; i < p; i++) + h[i] = fg[i]; + } + + protected static void checkForSmallPolynomial(byte[] r, byte[] ev, int p, int w) + { + int weight = 0; + for (int i = 0; i != ev.length; i++) + { + weight += ev[i] & 1; + } + + int mask = checkNotEqualToZero(weight - w); + for (int i = 0; i < w; i++) + r[i] = (byte)(((ev[i] ^ 1) & ~mask) ^ 1); + for (int i = w; i < p; i++) + r[i] = (byte)(ev[i] & ~mask); + } + + protected static void updateDiffMask(byte[] encR, byte[] rho, int mask) + { + for (int i = 0; i < encR.length; i++) + encR[i] ^= mask & (encR[i] ^ rho[i]); + } + + private static int[] getUnsignedDivMod(int dividend, int n) + { + long x = iToUnsignedLong(dividend); + long v = iToUnsignedLong(0x80000000); + long q, qpart, mask; + + v /= n; + q = 0; + + qpart = (x * v) >>> 31; + x -= qpart * n; + q += qpart; + + qpart = (x * v) >>> 31; + x -= qpart * n; + q += qpart; + + x -= n; + q += 1; + mask = -(x >>> 63); + x += mask & n; + q += mask; + + return new int[]{toIntExact(q), toIntExact(x)}; + } + + private static int[] getSignedDivMod(int x, int n) + { + int q, r, mask; + + int[] div1 = getUnsignedDivMod(toIntExact(0x80000000 + iToUnsignedLong(x)), n); + int[] div2 = getUnsignedDivMod(0x80000000, n); + + q = toIntExact(iToUnsignedLong(div1[0]) - iToUnsignedLong(div2[0])); + r = toIntExact(iToUnsignedLong(div1[1]) - iToUnsignedLong(div2[1])); + mask = -(r >>> 31); + r += mask & n; + q += mask; + + return new int[]{q, r}; + } + + private static int checkLessThanZero(int x) + { + return -(int)(x >>> 31); + } + + private static int checkNotEqualToZero(int x) + { + long l = iToUnsignedLong(x); + l = -l; + return -(int)(l >>> 63); + } + + static int bToUnsignedInt(byte b) + { + return b & 0xff; + } + + static int sToUnsignedInt(short s) + { + return s & 0xffff; + } + + static long iToUnsignedLong(int i) + { + return i & 0xffffffffL; + } + + static int toIntExact(long l) + { + int i = (int)l; + + if (i != l) + { + throw new IllegalStateException("value out of integer range"); + } + return i; + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/ConnectionInfo.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/ConnectionInfo.kt index c68bcd96..eede3fa6 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/ConnectionInfo.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/ConnectionInfo.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,7 +37,7 @@ data class ConnectionInfo( /** * True if the key exchange algorithm is post-quantum secure. * - * Currently only `mlkem768x25519-sha256` qualifies. + * Includes ML-KEM/X25519 and SNTRUP761/X25519 hybrid exchanges. */ val isPostQuantumSecure: Boolean get() = KexEntry.fromSshName(kexAlgorithm)?.isPostQuantum == true diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/HostKeyVerifier.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/HostKeyVerifier.kt index 205df47b..1e2d8206 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/HostKeyVerifier.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/HostKeyVerifier.kt @@ -35,10 +35,19 @@ interface HostKeyVerifier { */ suspend fun verify(key: PublicKey): Boolean + /** + * Persist the supported keys advertised by an authenticated server after every + * requested proof of private-key possession has been verified. Runs outside + * packet routing. Read-only verifiers may keep the default implementation. + */ suspend fun addKeys(keys: List) { // Optional persistence hook; read-only verifiers have nothing to store. } + /** + * Retire the authenticated key when omitted from the advertisement, after + * [addKeys] returns successfully. Other stored keys are not enumerated or removed. + */ suspend fun removeKeys(keys: List) { // Optional persistence hook; read-only verifiers have nothing to remove. } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt index 5b87a0c1..58acf94f 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/client/SshConnection.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -63,11 +64,13 @@ import org.connectbot.sshlib.crypto.CipherEntry import org.connectbot.sshlib.crypto.CompressionEntry import org.connectbot.sshlib.crypto.DiffieHellmanGroupExchange import org.connectbot.sshlib.crypto.EncryptionInstance +import org.connectbot.sshlib.crypto.HOSTKEYS_PROVE import org.connectbot.sshlib.crypto.KexAlgorithm import org.connectbot.sshlib.crypto.KexEntry import org.connectbot.sshlib.crypto.KexType import org.connectbot.sshlib.crypto.KeyDerivation import org.connectbot.sshlib.crypto.MacEntry +import org.connectbot.sshlib.crypto.MldsaAlgorithm import org.connectbot.sshlib.crypto.PacketAead import org.connectbot.sshlib.crypto.PacketCipher import org.connectbot.sshlib.crypto.PacketCompressor @@ -76,6 +79,7 @@ import org.connectbot.sshlib.crypto.SignatureEntry import org.connectbot.sshlib.crypto.SignatureVerifier import org.connectbot.sshlib.crypto.SshPrivateKey import org.connectbot.sshlib.crypto.SshPublicKeyEncoder +import org.connectbot.sshlib.crypto.verifyHostKeyProofs import org.connectbot.sshlib.kaitaiParseFailureOrNull import org.connectbot.sshlib.protocol.AsciiString import org.connectbot.sshlib.protocol.ChannelOpenDirectTcpip @@ -84,8 +88,11 @@ import org.connectbot.sshlib.protocol.ChannelOpenSession import org.connectbot.sshlib.protocol.ChannelRequestExitSignal import org.connectbot.sshlib.protocol.ChannelRequestExitStatus import org.connectbot.sshlib.protocol.GlobalRequestCancelTcpipForward +import org.connectbot.sshlib.protocol.GlobalRequestHostkeys00Openssh +import org.connectbot.sshlib.protocol.GlobalRequestHostkeysProve00Openssh import org.connectbot.sshlib.protocol.GlobalRequestResponseTcpipForward import org.connectbot.sshlib.protocol.GlobalRequestTcpipForward +import org.connectbot.sshlib.protocol.HostKeyUpdateStateMachine import org.connectbot.sshlib.protocol.IdBanner import org.connectbot.sshlib.protocol.KexDhGexPayload import org.connectbot.sshlib.protocol.KexEcdhPayload @@ -378,6 +385,7 @@ class SshConnection( private var sharedSecret: ByteArray? = null private var exchangeHash: ByteArray? = null private var sessionId: ByteArray? = null + private var initialHostKeyAlgorithm: String? = null private var negotiatedKex: String? = null private var negotiatedHostKeyAlgorithm: String? = null @@ -470,7 +478,8 @@ class SshConnection( // Pending async operations - completed by callbacks private val pendingAuth = PendingValue() private val pendingChannelRequests = HashMap>() - private val pendingGlobalRequest = PendingValue() + private val pendingGlobalRequests = ArrayDeque>() + private val hostKeyUpdates = HostKeyUpdateStateMachine() private class RemoteForwarderRegistration( val scope: CoroutineScope, @@ -1538,7 +1547,10 @@ class SshConnection( check( stateMachine.hostKeyVerified { serverHostKeyBlob = serverHostKey - if (sessionId == null) sessionId = hash.copyOf() + if (sessionId == null) { + sessionId = hash.copyOf() + initialHostKeyAlgorithm = negotiatedAlg + } clientKexInit?.fill(0) clientKexInit = null serverKexInit?.fill(0) @@ -2249,12 +2261,85 @@ class SshConnection( logger.debug("Received global request: $requestName (want_reply=$wantReply)") + if (requestName == "hostkeys-00@openssh.com" && !wantReply) { + val fields = (msg.requestSpecificFields() as GlobalRequestHostkeys00Openssh).hostkeys() + if (fields.size > 128) return + val advertised = fields.map { it.data().copyOf() } + val sid = sessionId?.copyOf() ?: return + val authenticatedKey = serverHostKeyBlob?.copyOf() ?: return + val negotiated = initialHostKeyAlgorithm ?: return + hostKeyUpdates.receive { + connectionScope.launch { + try { + withTimeout(30_000) { updateHostKeys(advertised, sid, authenticatedKey, negotiated) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("Host key update failed", e) + } + } + } + return + } + if (wantReply) { logger.debug("Sending REQUEST_FAILURE for unhandled global request: $requestName") writePacket(SshEnums.MessageType.SSH_MSG_REQUEST_FAILURE.id().toInt()) } } + /** Runs outside the protocol owner: verifier callbacks may suspend or perform disk I/O. */ + private suspend fun updateHostKeys(advertised: List, sid: ByteArray, authenticatedKey: ByteArray, negotiated: String) { + if (advertised.isEmpty() || advertised.size > 128) return + val allowed = hostKeyAlgorithms.split(',').toSet() + val unique = HashSet() + val keys = advertised.mapNotNull { blob -> + val type = AsciiString(ByteBufferKaitaiStream(blob)).apply { _read() }.value() + val key = PublicKey(type, blob) + if (!unique.add(key)) throw SshException("Duplicate advertised host key") + val supported = if (key.type == "ssh-rsa") { + allowed.any { it == "rsa-sha2-256" || it == "rsa-sha2-512" } + } else { + key.type in allowed && SignatureEntry.fromSshName(key.type) != null && + (MldsaAlgorithm.fromSshName(key.type)?.isAvailable() != false) + } + key.takeIf { supported } + } + if (keys.isEmpty()) return + val data = GlobalRequestHostkeysProve00Openssh().apply { + setHostkeys(ArrayList(keys.map { createByteString(it.encoded) })) + _check() + } + val request = SshMsgGlobalRequest().apply { + setRequestName(createAsciiString(HOSTKEYS_PROVE)) + setWantReply(1) + setRequestSpecificFields(data) + _check() + } + // repeat-eos Kaitai writers require an exactly sized stream. + val size = 4 + HOSTKEYS_PROVE.length + 1 + keys.sumOf { 4 + it.encoded.size } + val payload = request.toByteArray(size.toLong()) + val deferred = CompletableDeferred() + protocolExecutor.run(ready = ::localCommandsReady) { + check(pendingGlobalRequests.size < 128) { "Too many pending global requests" } + pendingGlobalRequests.addLast(deferred) + writePacket(SshEnums.MessageType.SSH_MSG_GLOBAL_REQUEST.id().toInt(), payload) + } + val reply = deferred.await() ?: return + if (!verifyHostKeyProofs(keys, reply, sid, negotiated, allowed)) { + logger.warn("Server supplied invalid host key proofs") + return + } + currentCoroutineContext().ensureActive() + hostKeyVerifier.addKeys(keys) + currentCoroutineContext().ensureActive() + // Only retire the key we authenticated with; unknown or disabled key types are preserved. + if (advertised.none { it.contentEquals(authenticatedKey) }) { + val type = AsciiString(ByteBufferKaitaiStream(authenticatedKey)).apply { _read() }.value() + hostKeyVerifier.removeKeys(listOf(PublicKey(type, authenticatedKey))) + } + } + // Port forwarding support internal suspend fun allocateChannelNumber(): Int { @@ -2352,18 +2437,16 @@ class SshConnection( val deferred = CompletableDeferred() protocolExecutor.run(ready = ::localCommandsReady) { - pendingGlobalRequest.setDirect(deferred) + check(pendingGlobalRequests.size < 128) { "Too many pending global requests" } + pendingGlobalRequests.addLast(deferred) writePacket( SshEnums.MessageType.SSH_MSG_GLOBAL_REQUEST.id().toInt(), msg.toByteArray(), ) } - val responseData = try { - deferred.await() - } finally { - pendingGlobalRequest.clearIfSame(deferred) - } ?: return null + // Cancelled callers retain their FIFO slot until the peer replies. + val responseData = deferred.await() ?: return null // If port was 0, the response contains the assigned port return if (port == 0 && responseData.isNotEmpty()) { @@ -2803,14 +2886,14 @@ class SshConnection( SshEnums.MessageType.SSH_MSG_REQUEST_SUCCESS -> { requireAccepted(stateMachine.authorizeAuthenticatedPacket(), msgType) val rawBody = packet._raw_body() - if (!pendingGlobalRequest.complete(rawBody)) { + if (pendingGlobalRequests.removeFirstOrNull()?.also { it.complete(rawBody) } == null) { throw ProtocolViolationException("Received SSH_MSG_REQUEST_SUCCESS with no pending global request") } } SshEnums.MessageType.SSH_MSG_REQUEST_FAILURE -> { requireAccepted(stateMachine.authorizeAuthenticatedPacket(), msgType) - if (!pendingGlobalRequest.complete(null)) { + if (pendingGlobalRequests.removeFirstOrNull()?.also { it.complete(null) } == null) { throw ProtocolViolationException("Received SSH_MSG_REQUEST_FAILURE with no pending global request") } } @@ -3158,7 +3241,8 @@ class SshConnection( pendingAuth.completeExceptionally(loopError) pendingChannelRequests.values.forEach { it.completeExceptionally(loopError) } pendingChannelRequests.clear() - pendingGlobalRequest.completeExceptionally(loopError) + pendingGlobalRequests.forEach { it.completeExceptionally(loopError) } + pendingGlobalRequests.clear() for ((_, pending) in pendingPings) { pending.deferred.complete(PingResult.Failure(loopError)) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt index 3ba072e3..ae0cee78 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/Algorithms.kt @@ -199,6 +199,36 @@ internal enum class KexEntry( true, { MlKemHybridKeyExchange() }, ), + MLKEM768NISTP256_SHA256( + "mlkem768nistp256-sha256", + HASH_SHA256, + KexType.ECDH, + true, + { MlKemNistHybridKeyExchange(MlKemNistParameters.NISTP256) }, + ), + MLKEM1024NISTP384_SHA384( + "mlkem1024nistp384-sha384", + "SHA-384", + KexType.ECDH, + true, + { MlKemNistHybridKeyExchange(MlKemNistParameters.NISTP384) }, + ), + MLKEM768_SHA256("mlkem768-sha256", HASH_SHA256, KexType.ECDH, true, { MlKemKeyExchange(MlKemParameterSet.ML_KEM_768) }), + MLKEM1024_SHA384("mlkem1024-sha384", "SHA-384", KexType.ECDH, true, { MlKemKeyExchange(MlKemParameterSet.ML_KEM_1024) }), + SNTRUP761X25519_SHA512( + "sntrup761x25519-sha512", + HASH_SHA512, + KexType.ECDH, + true, + { SntrupHybridKeyExchange() }, + ), + SNTRUP761X25519_SHA512_OPENSSH( + "sntrup761x25519-sha512@openssh.com", + HASH_SHA512, + KexType.ECDH, + true, + { SntrupHybridKeyExchange() }, + ), CURVE25519_SHA256( "curve25519-sha256", HASH_SHA256, @@ -282,7 +312,7 @@ internal enum class KexEntry( companion object { val defaults: List = entries.filter { - it !in setOf(DH_GROUP14_SHA1, DH_GROUP_EXCHANGE_SHA1, DH_GROUP1_SHA1) + it !in setOf(MLKEM768_SHA256, MLKEM1024_SHA384, DH_GROUP14_SHA1, DH_GROUP_EXCHANGE_SHA1, DH_GROUP1_SHA1) } val defaultString: String = @@ -297,6 +327,7 @@ internal enum class SignatureEntry( internal val algorithm: SshSignatureAlgorithm, ) { SSH_MLDSA44_ED25519("ssh-mldsa44-ed25519", MldsaSignatureAlgorithm(MldsaAlgorithm.MLDSA44_ED25519)), + SSH_MLDSA87_P384("ssh-mldsa87-p384", MldsaSignatureAlgorithm(MldsaAlgorithm.MLDSA87_P384)), SSH_MLDSA44("ssh-mldsa-44", MldsaSignatureAlgorithm(MldsaAlgorithm.MLDSA44)), SSH_MLDSA65("ssh-mldsa-65", MldsaSignatureAlgorithm(MldsaAlgorithm.MLDSA65)), SSH_MLDSA87("ssh-mldsa-87", MldsaSignatureAlgorithm(MldsaAlgorithm.MLDSA87)), @@ -316,6 +347,7 @@ internal enum class SignatureEntry( get() = entries.filter { when (it) { SSH_RSA, SSH_MLDSA44, SSH_MLDSA65, SSH_MLDSA87 -> false + SSH_MLDSA87_P384 -> MldsaAlgorithm.MLDSA87_P384.isAvailable() SSH_MLDSA44_ED25519 -> MldsaAlgorithm.MLDSA44_ED25519.isAvailable() else -> true } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt index edc50007..5938cab5 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/EcdhKeyExchange.kt @@ -23,6 +23,7 @@ import java.security.AlgorithmParameters import java.security.KeyPair import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey +import java.security.spec.ECFieldFp import java.security.spec.ECGenParameterSpec import java.security.spec.ECParameterSpec import java.security.spec.ECPoint @@ -67,6 +68,7 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { * @return Q_C as SEC1 uncompressed point (0x04 || x || y) */ override fun generateClientKeys(): ByteArray { + zeroize() val kpg = KeyPairGenerator.getInstance("EC") kpg.initialize(ECGenParameterSpec(jcaCurveName)) clientKeyPair = kpg.generateKeyPair() @@ -83,6 +85,16 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { * @return shared secret K as BigInteger byte array (positive, suitable for mpint encoding) */ override fun computeSharedSecret(serverPublicKey: ByteArray): ByteArray { + val rawSecret = computeRawSharedSecret(serverPublicKey) + return try { + computeSharedSecretFromRaw(rawSecret) + } finally { + rawSecret.fill(0) + } + } + + /** Fixed-width, unsigned, big-endian ECDH output for RFC 10042 hybrid KEX. */ + internal fun computeRawSharedSecret(serverPublicKey: ByteArray): ByteArray { val kp = clientKeyPair ?: throw SshException("Client keys not generated; call generateClientKeys() first") try { @@ -95,7 +107,13 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { agreement.doPhase(serverPubKey, true) val rawSecret = agreement.generateSecret() - return computeSharedSecretFromRaw(rawSecret) + var nonZero = 0 + for (byte in rawSecret) nonZero = nonZero or byte.toInt() + if (rawSecret.size != fieldSize || nonZero == 0) { + rawSecret.fill(0) + throw SshException("Invalid ECDH shared secret length or all-zero secret") + } + return rawSecret } catch (e: SshException) { throw e } catch (e: Exception) { @@ -138,6 +156,12 @@ internal class EcdhKeyExchange(private val curveName: String) : KexAlgorithm { } val x = BigInteger(1, encoded, 1, fieldSize) val y = BigInteger(1, encoded, 1 + fieldSize, fieldSize) + val p = (ecParameterSpec.curve.field as ECFieldFp).p + if (x >= p || y >= p || + y.multiply(y).mod(p) != x.multiply(x).multiply(x).add(ecParameterSpec.curve.a.multiply(x)).add(ecParameterSpec.curve.b).mod(p) + ) { + throw SshException("Invalid EC point: coordinates out of range or point not on curve") + } return ECPoint(x, y) } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt new file mode 100644 index 00000000..d691a32c --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/HostKeyProof.kt @@ -0,0 +1,56 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.protocol.GlobalRequestResponseHostkeysProve00Openssh +import org.connectbot.sshlib.protocol.SshPublicKey +import org.connectbot.sshlib.protocol.SshSignature + +internal const val HOSTKEYS_PROVE = "hostkeys-prove-00@openssh.com" + +internal fun hostKeyProofData(sessionId: ByteArray, key: ByteArray): ByteArray = encodeSshString(HOSTKEYS_PROVE.toByteArray(Charsets.US_ASCII)) + encodeSshString(sessionId) + encodeSshString(key) + +/** Verify the entire ordered reply before allowing any persistence, including retirement of old keys. */ +internal fun verifyHostKeyProofs( + keys: List, + reply: ByteArray, + sessionId: ByteArray, + negotiatedAlgorithm: String, + allowedAlgorithms: Set, +): Boolean = try { + val signatures = GlobalRequestResponseHostkeysProve00Openssh(ByteBufferKaitaiStream(reply)).apply { _read() }.signatures() + signatures.size == keys.size && keys.zip(signatures).all { (key, signature) -> + val bytes = signature.data() + val parsedSignature = SshSignature(ByteBufferKaitaiStream(bytes)).apply { _read() } + val parsedKey = SshPublicKey(ByteBufferKaitaiStream(key.encoded)).apply { _read() } + if (!parsedSignature._io().isEof || !parsedKey._io().isEof || parsedKey.algorithmName() != key.type) return@all false + val algorithm = parsedSignature.algorithmName() + val permitted = if (key.type == "ssh-rsa") { + // The legacy OpenSSH request permits SHA-1, but we require SHA-2 proofs. + algorithm in setOf("rsa-sha2-256", "rsa-sha2-512") && algorithm in allowedAlgorithms && + (negotiatedAlgorithm !in setOf("ssh-rsa", "rsa-sha2-256", "rsa-sha2-512") || algorithm == negotiatedAlgorithm) + } else { + algorithm == key.type && algorithm in allowedAlgorithms + } + permitted && SignatureVerifier.verify(key.encoded, bytes, hostKeyProofData(sessionId, key.encoded), algorithm) + } +} catch (_: Exception) { + false +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KexAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KexAlgorithm.kt index 806b0429..b4b2457e 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KexAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KexAlgorithm.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,7 +37,7 @@ internal interface KexAlgorithm { * * The returned bytes are the wire-encoded K, ready for use in the exchange * hash and key derivation. For classic DH/ECDH this is mpint encoding - * (uint32 length + positive integer bytes). For ML-KEM hybrid this is SSH + * (uint32 length + positive integer bytes). For hybrid KEX this is SSH * string encoding (uint32 length + raw hash bytes). */ fun computeSharedSecret(serverPublicKey: ByteArray): ByteArray diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProvider.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProvider.kt index f1894d87..9b827e0f 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProvider.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProvider.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,12 +21,13 @@ import asia.hombre.kyber.KyberCipherText import asia.hombre.kyber.KyberDecapsulationKey import asia.hombre.kyber.KyberEncapsulationKey import asia.hombre.kyber.KyberKeyGenerator -import asia.hombre.kyber.KyberParameter import asia.hombre.kyber.interfaces.RandomProvider import java.io.IOException import java.security.SecureRandom -internal class KyberKotlinMlKemProvider : MlKemProvider { +internal class KyberKotlinMlKemProvider( + private val parameterSet: MlKemParameterSet = MlKemParameterSet.ML_KEM_768, +) : MlKemProvider { private val randomProvider = object : RandomProvider { private val secureRandom = SecureRandom() override fun fillWithRandom(byteArray: ByteArray) { @@ -36,7 +37,7 @@ internal class KyberKotlinMlKemProvider : MlKemProvider { override fun generateKeyPair(): MlKemKeyPair { try { - val keyPair = KyberKeyGenerator.generate(KyberParameter.ML_KEM_768, randomProvider) + val keyPair = KyberKeyGenerator.generate(parameterSet.kyberParameter, randomProvider) return MlKemKeyPair( publicKey = keyPair.encapsulationKey.fullBytes, privateKey = keyPair.decapsulationKey.fullBytes, @@ -48,6 +49,9 @@ internal class KyberKotlinMlKemProvider : MlKemProvider { override fun encapsulate(publicKey: ByteArray): MlKemEncapsulationResult { try { + if (publicKey.size != parameterSet.publicKeySize) { + throw IOException("Invalid $parameterSet public key length: ${publicKey.size}") + } val encapsKey = KyberEncapsulationKey.fromBytes(publicKey) val result = encapsKey.encapsulate(randomProvider) return MlKemEncapsulationResult( @@ -61,6 +65,9 @@ internal class KyberKotlinMlKemProvider : MlKemProvider { override fun decapsulate(privateKey: ByteArray, ciphertext: ByteArray): ByteArray { try { + if (privateKey.size != parameterSet.privateKeySize || ciphertext.size != parameterSet.ciphertextSize) { + throw IOException("Invalid $parameterSet private key or ciphertext length") + } val decapsKey = KyberDecapsulationKey.fromBytes(privateKey) val ct = KyberCipherText.fromBytes(ciphertext) return decapsKey.decapsulate(ct) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchange.kt new file mode 100644 index 00000000..20e5a3aa --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchange.kt @@ -0,0 +1,70 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.SshException + +/** Pure ML-KEM SSH exchange; draft-harrison-sshm-mlkem-02 section 3. */ +internal class MlKemKeyExchange( + private val parameterSet: MlKemParameterSet, + private val provider: MlKemProvider = MlKemProviderFactory.forParameterSet(parameterSet), +) : KexAlgorithm { + override val hashAlgorithm: String = if (parameterSet == MlKemParameterSet.ML_KEM_1024) "SHA-384" else "SHA-256" + private var privateKey: ByteArray? = null + + override fun generateClientKeys(): ByteArray { + zeroize() + try { + val keys = provider.generateKeyPair() + privateKey = keys.privateKey + // The provider owns the private-key representation (raw Kotlin or PKCS#8 JCA). + if (keys.publicKey.size != parameterSet.publicKeySize || keys.privateKey.isEmpty()) { + throw SshException("Invalid $parameterSet key pair size") + } + return keys.publicKey + } catch (e: Exception) { + zeroize() + if (e is SshException) throw e + throw SshException("ML-KEM key generation failed", e) + } + } + + override fun computeSharedSecret(serverPublicKey: ByteArray): ByteArray { + val key = privateKey ?: throw SshException("Client keys not generated") + if (serverPublicKey.size != parameterSet.ciphertextSize) throw SshException("Invalid $parameterSet ciphertext size") + try { + val secret = provider.decapsulate(key, serverPublicKey) + return try { + if (secret.size != 32) throw SshException("Invalid ML-KEM shared secret size") + // Section 3 and CycloneSSH use string K_PQ, preserving all 32 octets. + // The per-method subsections' mpint wording contradicts that definition. + encodeSshString(secret) + } finally { + secret.fill(0) + } + } catch (e: Exception) { + if (e is SshException) throw e + throw SshException("ML-KEM decapsulation failed", e) + } + } + + override fun zeroize() { + privateKey?.fill(0) + privateKey = null + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchange.kt new file mode 100644 index 00000000..14fddda6 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchange.kt @@ -0,0 +1,112 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.protocol.SshHybridKexBlob +import org.connectbot.sshlib.protocol.toByteArray +import java.security.MessageDigest + +internal enum class MlKemNistParameters( + val mlKem: MlKemParameterSet, + val curveName: String, + val fieldSize: Int, + val hashAlgorithm: String, +) { + NISTP256(MlKemParameterSet.ML_KEM_768, "nistp256", 32, "SHA-256"), + NISTP384(MlKemParameterSet.ML_KEM_1024, "nistp384", 48, "SHA-384"), +} + +/** RFC 10042 sections 2.3.1 and 2.3.2, using Android-compatible ML-KEM and JCA ECDH. */ +internal class MlKemNistHybridKeyExchange( + private val parameters: MlKemNistParameters, + private val mlKemProvider: MlKemProvider = MlKemProviderFactory.forParameterSet(parameters.mlKem), +) : KexAlgorithm { + override val hashAlgorithm: String = parameters.hashAlgorithm + private val ecdh = EcdhKeyExchange(parameters.curveName) + private var mlKemPrivateKey: ByteArray? = null + private val ecPublicKeySize = 1 + 2 * parameters.fieldSize + + override fun generateClientKeys(): ByteArray { + zeroize() + try { + val keys = mlKemProvider.generateKeyPair() + mlKemPrivateKey = keys.privateKey + if (keys.publicKey.size != parameters.mlKem.publicKeySize) { + throw SshException("Unexpected ${parameters.mlKem} public key length: ${keys.publicKey.size}") + } + val classicalPublic = ecdh.generateClientKeys() + if (classicalPublic.size != ecPublicKeySize) throw SshException("Invalid ECDH public key length") + return SshHybridKexBlob(parameters.mlKem.publicKeySize.toLong(), ecPublicKeySize.toLong()).apply { + setPqData(keys.publicKey) + setClassicalData(classicalPublic) + }.toByteArray() + } catch (e: Exception) { + zeroize() + if (e is SshException) throw e + throw SshException("ML-KEM/NIST hybrid key generation failed", e) + } + } + + override fun computeSharedSecret(serverPublicKey: ByteArray): ByteArray { + val privateKey = mlKemPrivateKey + ?: throw SshException("Client keys not generated; call generateClientKeys() first") + val ciphertextSize = parameters.mlKem.ciphertextSize + val expectedSize = ciphertextSize + ecPublicKeySize + // Check the complete blob before either decapsulation or ECDH. + if (serverPublicKey.size != expectedSize) { + throw SshException("Invalid S_REPLY length: ${serverPublicKey.size} (expected $expectedSize)") + } + var classicalSecret: ByteArray? = null + var pqSecret: ByteArray? = null + try { + val reply = SshHybridKexBlob(ByteBufferKaitaiStream(serverPublicKey), ciphertextSize.toLong(), ecPublicKeySize.toLong()).apply { _read() } + classicalSecret = ecdh.computeRawSharedSecret(reply.classicalData()) + pqSecret = mlKemProvider.decapsulate(privateKey, reply.pqData()) + return combineSharedSecrets(pqSecret, classicalSecret) + } catch (e: Exception) { + if (e is SshException) throw e + throw SshException("ML-KEM/NIST hybrid key exchange failed", e) + } finally { + pqSecret?.fill(0) + classicalSecret?.fill(0) + } + } + + internal fun combineSharedSecrets(pqSecret: ByteArray, classicalSecret: ByteArray): ByteArray { + var nonZero = 0 + for (byte in classicalSecret) nonZero = nonZero or byte.toInt() + if (pqSecret.size != 32 || classicalSecret.size != parameters.fieldSize || nonZero == 0) { + throw SshException("Invalid ML-KEM/NIST shared secret length or all-zero ECDH secret") + } + // RFC 10042 section 2.4: fixed-width octet strings, never mpint/sign padding. + val combined = pqSecret + classicalSecret + return try { + encodeSshString(MessageDigest.getInstance(hashAlgorithm).digest(combined)) + } finally { + combined.fill(0) + } + } + + override fun zeroize() { + mlKemPrivateKey?.fill(0) + mlKemPrivateKey = null + ecdh.zeroize() + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemParameterSet.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemParameterSet.kt new file mode 100644 index 00000000..76e40ec5 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemParameterSet.kt @@ -0,0 +1,31 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import asia.hombre.kyber.KyberParameter + +/** The FIPS 203 parameter sets used by the RFC 10042 SSH methods. */ +internal enum class MlKemParameterSet(val kyberParameter: KyberParameter) { + ML_KEM_768(KyberParameter.ML_KEM_768), + ML_KEM_1024(KyberParameter.ML_KEM_1024), + ; + + val publicKeySize: Int get() = kyberParameter.ENCAPSULATION_KEY_LENGTH + val privateKeySize: Int get() = kyberParameter.DECAPSULATION_KEY_LENGTH + val ciphertextSize: Int get() = kyberParameter.CIPHERTEXT_LENGTH +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemProviderFactory.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemProviderFactory.kt index 5ae847c7..63bf6cf1 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemProviderFactory.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MlKemProviderFactory.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,4 +32,14 @@ internal object MlKemProviderFactory { KyberKotlinMlKemProvider() } } + + // The bundled Kotlin provider supplies ML-KEM-1024 on Android and JVM 17. + private val provider1024: MlKemProvider by lazy { + KyberKotlinMlKemProvider(MlKemParameterSet.ML_KEM_1024) + } + + fun forParameterSet(parameterSet: MlKemParameterSet): MlKemProvider = when (parameterSet) { + MlKemParameterSet.ML_KEM_768 -> provider + MlKemParameterSet.ML_KEM_1024 -> provider1024 + } } diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaAlgorithm.kt index 1bd4cbc6..e86b3e72 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaAlgorithm.kt @@ -34,7 +34,7 @@ internal enum class MldsaAlgorithm( val sshName: String, val jcaName: String, val publicKeySize: Int, - val signatureSize: Int, + val maxSignatureSize: Int, val oid: ByteArray, ) { MLDSA44("ssh-mldsa-44", "ML-DSA-44", 1312, 2420, byteArrayOf(0x60, 0x86.toByte(), 0x48, 1, 0x65, 3, 4, 3, 17)), @@ -47,8 +47,17 @@ internal enum class MldsaAlgorithm( 2484, byteArrayOf(0x2b, 6, 1, 5, 5, 7, 6, 39), ), + MLDSA87_P384( + "ssh-mldsa87-p384", + "MLDSA87-ECDSA-P384-SHA512", + 2689, + 4731, + byteArrayOf(0x2b, 6, 1, 5, 5, 7, 6, 49), + ), ; + val isComposite: Boolean get() = this == MLDSA44_ED25519 || this == MLDSA87_P384 + // Android providers may expose only the generic service, with the parameter set taken from the key. private val serviceNames = if (jcaName.startsWith("ML-DSA-")) listOf(jcaName, "ML-DSA") else listOf(jcaName) @@ -143,6 +152,14 @@ internal enum class MldsaAlgorithm( ) ed25519.copyInto(rawKey, 1312) } + if (this == MLDSA87_P384) { + // A valid public P-384 base point lets providers validate the entire composite key. + val x = java.math.BigInteger("AA87CA22BE8B05378EB1C71EF320AD746E1D3B628BA79B9859F741E082542A385502F25DBF55296C3A545E3872760AB7", 16) + val y = java.math.BigInteger("3617DE4A96262C6F5D9E98BF9292DC29F8F41DBD289A147CE9DA3113B5F0B8C00A60B1CE1D7E819D7A431D7C90EA0E5F", 16) + rawKey[2592] = 4 + x.toByteArray().takeLast(48).toByteArray().copyInto(rawKey, 2593) + y.toByteArray().takeLast(48).toByteArray().copyInto(rawKey, 2641) + } verifier(rawKey, providers) true } catch (_: SshException) { diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaP384Codec.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaP384Codec.kt new file mode 100644 index 00000000..273c305d --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaP384Codec.kt @@ -0,0 +1,110 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.protocol.Mpint +import org.connectbot.sshlib.protocol.SshMldsaP384PublicKeyBlob +import org.connectbot.sshlib.protocol.SshMldsaP384SignatureBlob +import org.connectbot.sshlib.protocol.SshPublicKey +import org.connectbot.sshlib.protocol.SshSignature +import org.connectbot.sshlib.protocol.toByteArray +import java.math.BigInteger +import java.security.GeneralSecurityException + +/** SSH wraps Q in a string and r/s in mpints; JCA uses raw Q and DER ECDSA. */ +internal object MldsaP384Codec { + private val parameter = MldsaAlgorithm.MLDSA87_P384 + private val order = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973", 16) + + private fun scalar(bytes: ByteArray): BigInteger? { + if (bytes.isEmpty() || bytes.size > 49) return null + val value = BigInteger(bytes) + return value.takeIf { it.signum() > 0 && it < order && it.toByteArray().contentEquals(bytes) } + } + + fun verify(key: SshPublicKey, signature: SshSignature, data: ByteArray): Boolean { + val pub = key.keyBlob() as? SshMldsaP384PublicKeyBlob ?: return false + val body = (signature.signatureBlob() as? SshMldsaP384SignatureBlob)?.signature() ?: return false + if (!body._io().isEof || pub.q()[0] != 4.toByte()) return false + val r = scalar(body.r().body()) ?: return false + val s = scalar(body.s().body()) ?: return false + val raw = body.mldsaSignature() + EcdsaSignatureAlgorithm.encodeDerEcdsaSignature(r, s) + val verifier = parameter.verifier(pub.mldsaKey() + pub.q()) + return try { + verifier.update(data) + verifier.verify(raw) + } catch (_: GeneralSecurityException) { + false + } catch (_: RuntimeException) { + false + } + } + + fun encodePublicKey(raw: ByteArray): ByteArray { + if (raw.size != 2689 || raw[2592] != 4.toByte()) throw SshException("Invalid ML-DSA-87/P-384 public key") + val body = SshMldsaP384PublicKeyBlob().apply { + setLenKey(2693) + setMldsaKey(raw.copyOfRange(0, 2592)) + setLenQ(97) + setQ(raw.copyOfRange(2592, raw.size)) + _check() + } + return SshPublicKey().apply { + setAlgorithmNameLen(parameter.sshName.length.toLong()) + setAlgorithmName(parameter.sshName) + setKeyBlob(body) + }.toByteArray() + } + + fun encodeSignature(raw: ByteArray): ByteArray { + if (raw.size !in 4635..4731) throw SshException("Invalid ML-DSA-87/P-384 signature size") + val reader = DerReader(raw.copyOfRange(4627, raw.size)) + val (r, s) = reader.readSequence { it.readInteger() to it.readInteger() } + reader.ensureFullyConsumed() + if (scalar(r.toByteArray()) == null || scalar(s.toByteArray()) == null) throw SshException("Invalid P-384 signature scalar") + val body = SshMldsaP384SignatureBlob() + val composite = SshMldsaP384SignatureBlob.CompositeSignature(null, body, body).apply { + setMldsaSignature(raw.copyOfRange(0, 4627)) + setR( + Mpint().apply { + setLenBody(r.toByteArray().size.toLong()) + setBody(r.toByteArray()) + _check() + }, + ) + setS( + Mpint().apply { + setLenBody(s.toByteArray().size.toLong()) + setBody(s.toByteArray()) + _check() + }, + ) + } + body.apply { + setLenSignature(composite.toByteArray().size.toLong()) + setSignature(composite) + _check() + } + return SshSignature().apply { + setAlgorithmNameLen(parameter.sshName.length.toLong()) + setAlgorithmName(parameter.sshName) + setSignatureBlob(body) + }.toByteArray() + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithm.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithm.kt index 5bf98d6f..b0e989b6 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithm.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithm.kt @@ -30,9 +30,10 @@ import java.security.PublicKey internal class MldsaSignatureAlgorithm(private val parameter: MldsaAlgorithm) : SshSignatureAlgorithm { override fun verify(pubKey: SshPublicKey, sig: SshSignature, data: ByteArray): Boolean { if (pubKey.algorithmName() != parameter.sshName || sig.algorithmName() != parameter.sshName) return false + if (parameter == MldsaAlgorithm.MLDSA87_P384) return MldsaP384Codec.verify(pubKey, sig, data) val key = (pubKey.keyBlob() as? SshMldsaPublicKeyBlob)?.key() ?: return false val signature = (sig.signatureBlob() as? SshMldsaSignatureBlob)?.signature() ?: return false - if (key.size != parameter.publicKeySize || signature.size != parameter.signatureSize) return false + if (key.size != parameter.publicKeySize || signature.size != parameter.maxSignatureSize) return false val verifier = parameter.verifier(key) return try { verifier.update(data) @@ -55,8 +56,9 @@ internal class MldsaSignatureAlgorithm(private val parameter: MldsaAlgorithm) : } catch (e: RuntimeException) { throw SshException("${parameter.sshName} signing failed", e) } - if (raw.size != parameter.signatureSize) throw SshException("Invalid ${parameter.sshName} signature size: ${raw.size}") - val body = SshMldsaSignatureBlob(parameter.signatureSize.toLong()).apply { + if (parameter == MldsaAlgorithm.MLDSA87_P384) return MldsaP384Codec.encodeSignature(raw) + if (raw.size != parameter.maxSignatureSize) throw SshException("Invalid ${parameter.sshName} signature size: ${raw.size}") + val body = SshMldsaSignatureBlob(parameter.maxSignatureSize.toLong()).apply { setLenSignature(raw.size.toLong()) setSignature(raw) _check() @@ -72,6 +74,7 @@ internal class MldsaSignatureAlgorithm(private val parameter: MldsaAlgorithm) : fun encodePublicKey(publicKey: PublicKey, keyType: String): ByteArray { val (parameter, raw) = MldsaAlgorithm.decodePublicKey(publicKey) if (parameter.sshName != keyType) throw SshException("ML-DSA key does not match $keyType") + if (parameter == MldsaAlgorithm.MLDSA87_P384) return MldsaP384Codec.encodePublicKey(raw) val body = SshMldsaPublicKeyBlob(parameter.publicKeySize.toLong()).apply { setLenKey(raw.size.toLong()) setKey(raw) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchange.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchange.kt new file mode 100644 index 00000000..dcd2c4c5 --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchange.kt @@ -0,0 +1,96 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.SshException +import org.connectbot.sshlib.crypto.sntrup.Sntrup761KeyPair +import org.connectbot.sshlib.protocol.SshHybridKexBlob +import org.connectbot.sshlib.protocol.toByteArray +import java.security.MessageDigest + +/** RFC 9941: SNTRUP761 + X25519, with the SHA-512 output encoded as an SSH string. */ +internal class SntrupHybridKeyExchange( + private val x25519Provider: X25519Provider = X25519ProviderFactory.provider, +) : KexAlgorithm { + override val hashAlgorithm: String = "SHA-512" + + private var sntrupKeys: Sntrup761KeyPair? = null + private var x25519PrivateKey: ByteArray? = null + + override fun generateClientKeys(): ByteArray { + zeroize() + try { + val keys = Sntrup761KeyPair.generate() + sntrupKeys = keys + val privateKey = x25519Provider.generatePrivateKey() + x25519PrivateKey = privateKey + val publicKey = x25519Provider.publicFromPrivate(privateKey) + if (keys.publicKey.size != 1158 || publicKey.size != 32) { + throw SshException("Invalid SNTRUP761/X25519 public key length") + } + return SshHybridKexBlob(1158.toLong(), 32.toLong()).apply { + setPqData(keys.publicKey) + setClassicalData(publicKey) + }.toByteArray() + } catch (e: Exception) { + zeroize() + if (e is SshException) throw e + throw SshException("SNTRUP hybrid key generation failed", e) + } + } + + override fun computeSharedSecret(serverPublicKey: ByteArray): ByteArray { + val keys = sntrupKeys ?: throw SshException("Client keys not generated; call generateClientKeys() first") + val privateKey = x25519PrivateKey ?: throw SshException("X25519 private key unavailable") + if (serverPublicKey.size != 1071) { + throw SshException("Invalid SNTRUP hybrid server key length: ${serverPublicKey.size} (expected 1071)") + } + var pqSecret: ByteArray? = null + var ecSecret: ByteArray? = null + var combined: ByteArray? = null + try { + val reply = SshHybridKexBlob(ByteBufferKaitaiStream(serverPublicKey), 1039, 32).apply { _read() } + pqSecret = keys.decapsulate(reply.pqData()) + ecSecret = x25519Provider.computeSharedSecret(privateKey, reply.classicalData()) + if (pqSecret.size != 32 || ecSecret.size != 32) { + throw SshException("Invalid SNTRUP761/X25519 shared secret length") + } + var nonZero = 0 + for (byte in ecSecret) nonZero = nonZero or byte.toInt() + if (nonZero == 0) throw SshException("Invalid X25519 shared secret; all zeroes") + // Preserve all 32 X25519 bytes, including leading zeros and the high bit. + combined = pqSecret + ecSecret + return encodeSshString(MessageDigest.getInstance(hashAlgorithm).digest(combined)) + } catch (e: Exception) { + if (e is SshException) throw e + throw SshException("SNTRUP hybrid key exchange failed", e) + } finally { + pqSecret?.fill(0) + ecSecret?.fill(0) + combined?.fill(0) + } + } + + override fun zeroize() { + sntrupKeys?.clear() + sntrupKeys = null + x25519PrivateKey?.fill(0) + x25519PrivateKey = null + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761KeyPair.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761KeyPair.kt new file mode 100644 index 00000000..ec79b44e --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761KeyPair.kt @@ -0,0 +1,56 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto.sntrup + +import org.connectbot.sshlib.SshException +import java.security.SecureRandom + +/** Internal boundary around the package-private, vendored BC implementation. */ +internal class Sntrup761KeyPair private constructor( + val publicKey: ByteArray, + private var privateKey: SNTRUPrimePrivateKeyParameters?, +) { + fun decapsulate(ciphertext: ByteArray): ByteArray { + val key = privateKey ?: throw SshException("SNTRUP761 private key has been cleared") + if (ciphertext.size != 1039) throw SshException("Invalid SNTRUP761 ciphertext length: ${ciphertext.size}") + return try { + SNTRUPrimeKEMExtractor(key).extractSecret(ciphertext) + } catch (e: Exception) { + throw SshException("SNTRUP761 decapsulation failed", e) + } + } + + fun clear() { + privateKey?.clear() + privateKey = null + } + + companion object { + fun generate(random: SecureRandom = SecureRandom()): Sntrup761KeyPair = try { + val generator = SNTRUPrimeKeyPairGenerator() + generator.init(SNTRUPrimeKeyGenerationParameters(random, SNTRUPrimeParameters.sntrup761)) + val pair = generator.generateKeyPair() + Sntrup761KeyPair( + (pair.getPublic() as SNTRUPrimePublicKeyParameters).encoded, + pair.getPrivate() as SNTRUPrimePrivateKeyParameters, + ) + } catch (e: Exception) { + throw SshException("SNTRUP761 key generation failed", e) + } + } +} diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/HostKeyUpdateStateMachine.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/HostKeyUpdateStateMachine.kt new file mode 100644 index 00000000..75abb33f --- /dev/null +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/HostKeyUpdateStateMachine.kt @@ -0,0 +1,43 @@ +/* + * ConnectBot SSH Library + * Copyright 2025-2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.protocol + +import ru.nsk.kstatemachine.event.Event +import ru.nsk.kstatemachine.state.initialState +import ru.nsk.kstatemachine.state.invoke +import ru.nsk.kstatemachine.state.state +import ru.nsk.kstatemachine.state.transition +import ru.nsk.kstatemachine.statemachine.ProcessingResult +import ru.nsk.kstatemachine.statemachine.createStdLibStateMachine +import ru.nsk.kstatemachine.statemachine.throwingPendingEventHandler +import ru.nsk.kstatemachine.transition.onTriggered + +/** One host-key advertisement per authenticated connection. Called only by the protocol owner. */ +internal class HostKeyUpdateStateMachine { + private class Advertisement(val begin: () -> Unit) : Event + + private val machine = createStdLibStateMachine { + pendingEventHandler = throwingPendingEventHandler() + val received = state("AdvertisementReceived") + initialState("AwaitAdvertisement") { + transition { targetState = received }.onTriggered { it.event.begin() } + } + } + + suspend fun receive(begin: () -> Unit): Boolean = machine.processEvent(Advertisement(begin)) == ProcessingResult.PROCESSED +} diff --git a/sshlib/src/main/resources/META-INF/LICENSE-sntrup.md b/sshlib/src/main/resources/META-INF/LICENSE-sntrup.md new file mode 100644 index 00000000..1f0172c5 --- /dev/null +++ b/sshlib/src/main/resources/META-INF/LICENSE-sntrup.md @@ -0,0 +1,15 @@ +MIT License (https://opensource.org/licenses/MIT) + +Copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org). +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sub license, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this +permission notice shall be included in all copies or substantial portions of the Software. + +**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT +OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/ConnectionInfoTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/ConnectionInfoTest.kt index 05f21c4c..89b750ea 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/ConnectionInfoTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/ConnectionInfoTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -37,6 +37,18 @@ class ConnectionInfoTest { assertTrue(info("mlkem768x25519-sha256").isPostQuantumSecure) } + @Test + fun `isPostQuantumSecure is true for both SNTRUP761 names`() { + assertTrue(info("sntrup761x25519-sha512").isPostQuantumSecure) + assertTrue(info("sntrup761x25519-sha512@openssh.com").isPostQuantumSecure) + } + + @Test + fun `isPostQuantumSecure is true for both ML-KEM NIST hybrids`() { + assertTrue(info("mlkem768nistp256-sha256").isPostQuantumSecure) + assertTrue(info("mlkem1024nistp384-sha384").isPostQuantumSecure) + } + @Test fun `isPostQuantumSecure is false for curve25519-sha256`() { assertFalse(info("curve25519-sha256").isPostQuantumSecure) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt index a8b7e815..97370639 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/FakeSshServer.kt @@ -28,11 +28,16 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKEMGenerator +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeParameters +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimePublicKeyParameters +import org.connectbot.sshlib.SshSigning import org.connectbot.sshlib.crypto.CipherEntry import org.connectbot.sshlib.crypto.EncryptionInstance import org.connectbot.sshlib.crypto.KeyDerivation import org.connectbot.sshlib.crypto.KyberKotlinMlKemProvider import org.connectbot.sshlib.crypto.MacEntry +import org.connectbot.sshlib.crypto.MlKemTestServer import org.connectbot.sshlib.crypto.SshPublicKeyEncoder import org.connectbot.sshlib.crypto.X25519ProviderFactory import org.connectbot.sshlib.crypto.encodeMpint @@ -50,6 +55,7 @@ import org.connectbot.sshlib.protocol.SshMsgChannelSuccess import org.connectbot.sshlib.protocol.SshMsgChannelWindowAdjust import org.connectbot.sshlib.protocol.SshMsgDisconnect import org.connectbot.sshlib.protocol.SshMsgExtInfo +import org.connectbot.sshlib.protocol.SshMsgGlobalRequest import org.connectbot.sshlib.protocol.SshMsgIgnore import org.connectbot.sshlib.protocol.SshMsgKexEcdhInit import org.connectbot.sshlib.protocol.SshMsgKexEcdhReply @@ -110,6 +116,13 @@ class FakeSshServer( var advertisePing: Boolean = false var advertiseExtInfo: Boolean = false var kexAlgorithms: String? = null + + private val kexHashAlgorithm: String + get() = when (kexAlgorithms?.split(',')?.first()) { + "mlkem1024nistp384-sha384", "mlkem1024-sha384" -> "SHA-384" + "sntrup761x25519-sha512", "sntrup761x25519-sha512@openssh.com" -> "SHA-512" + else -> "SHA-256" + } var corruptKexSignature: Boolean = false var transformKexServerPublic: (ByteArray) -> ByteArray = { it } var truncateKexReply: Boolean = false @@ -128,6 +141,11 @@ class FakeSshServer( private val receivedChannelData = Channel(Channel.UNLIMITED) private val receivedChannelWindowAdjusts = Channel(Channel.UNLIMITED) private val receivedUnimplemented = Channel(Channel.UNLIMITED) + private val receivedGlobalRequests = Channel(Channel.UNLIMITED) + suspend fun awaitGlobalRequest(): SshMsgGlobalRequest = receivedGlobalRequests.receive() + fun signHostKeyData(data: ByteArray): ByteArray = SshSigning.signWithKeyPair("ssh-ed25519", hostKeyPair, data) + val sessionIdentifier: ByteArray get() = requireNotNull(sessionId).copyOf() + private val receivedClosingPackets = Channel(Channel.UNLIMITED) suspend fun awaitClosingPacket(): SshEnums.MessageType = receivedClosingPackets.receive() @@ -252,6 +270,11 @@ class FakeSshServer( receivedUserauthRequests.trySend(request) } + SshEnums.MessageType.SSH_MSG_GLOBAL_REQUEST -> { + val request = SshMsgGlobalRequest(ByteBufferKaitaiStream(rawBytes.copyOfRange(1, rawBytes.size))).apply { _read() } + receivedGlobalRequests.trySend(request) + } + SshEnums.MessageType.SSH_MSG_CHANNEL_OPEN -> { val bodyBytes = rawBytes.copyOfRange(1, rawBytes.size) val request = SshMsgChannelOpen(ByteBufferKaitaiStream(bodyBytes)) @@ -439,10 +462,30 @@ class FakeSshServer( val serverPrivate = x25519.generatePrivateKey() val serverX25519Public = x25519.publicFromPrivate(serverPrivate) - val hybrid = kexAlgorithms?.split(',')?.first() == "mlkem768x25519-sha256" + val algorithm = kexAlgorithms?.split(',')?.first() + val isMlKemX25519 = algorithm == "mlkem768x25519-sha256" val serverPublic: ByteArray val sharedSecret: ByteArray - if (hybrid) { + if (algorithm == "sntrup761x25519-sha512" || algorithm == "sntrup761x25519-sha512@openssh.com") { + check(clientPublic.size == 1190) + val publicKey = SNTRUPrimePublicKeyParameters(SNTRUPrimeParameters.sntrup761, clientPublic.copyOfRange(0, 1158)) + val encapsulation = SNTRUPrimeKEMGenerator(SecureRandom()).generateEncapsulated(publicKey) + try { + val classicalSecret = x25519.computeSharedSecret(serverPrivate, clientPublic.copyOfRange(1158, 1190)) + serverPublic = transformKexServerPublic(encapsulation.encapsulation + serverX25519Public) + sharedSecret = encodeSshString(MessageDigest.getInstance("SHA-512").digest(encapsulation.secret + classicalSecret)) + } finally { + encapsulation.destroy() + } + } else if (algorithm == "mlkem768-sha256" || algorithm == "mlkem1024-sha384") { + val (reply, secret) = MlKemTestServer.pureReply(clientPublic, algorithm == "mlkem1024-sha384") + serverPublic = transformKexServerPublic(reply) + sharedSecret = secret + } else if (algorithm == "mlkem768nistp256-sha256" || algorithm == "mlkem1024nistp384-sha384") { + val (reply, secret) = MlKemTestServer.reply(clientPublic, algorithm == "mlkem1024nistp384-sha384") + serverPublic = transformKexServerPublic(reply) + sharedSecret = secret + } else if (isMlKemX25519) { check(clientPublic.size == 1216) val encapsulation = KyberKotlinMlKemProvider().encapsulate(clientPublic.copyOfRange(0, 1184)) val classicalSecret = x25519.computeSharedSecret(serverPrivate, clientPublic.copyOfRange(1184, 1216)) @@ -492,7 +535,7 @@ class FakeSshServer( val exchangeHash = latestExchangeHash ?: return val sid = sessionId ?: return - val keyDerivation = KeyDerivation(sharedSecret, exchangeHash, sid, "SHA-256") + val keyDerivation = KeyDerivation(sharedSecret, exchangeHash, sid, kexHashAlgorithm) val cipherEntry = CipherEntry.fromSshName("aes128-ctr") ?: return val macEntry = MacEntry.fromSshName("hmac-sha2-256-etm@openssh.com") ?: return @@ -539,7 +582,7 @@ class FakeSshServer( serverPublicKey: ByteArray, sharedSecret: ByteArray, ): ByteArray { - val md = MessageDigest.getInstance("SHA-256") + val md = MessageDigest.getInstance(kexHashAlgorithm) val buf = ByteArrayOutputStream() fun writeString(data: ByteArray) { @@ -936,10 +979,11 @@ class FakeSshServer( } } - suspend fun sendGlobalRequest(requestName: String, wantReply: Boolean) { + suspend fun sendGlobalRequest(requestName: String, wantReply: Boolean, data: ByteArray = byteArrayOf()) { val payload = ByteArrayOutputStream() payload.writeString(requestName.toByteArray(Charsets.US_ASCII)) payload.write(if (wantReply) 1 else 0) + payload.write(data) writeMutex.withLock { serverIo.writePacket(SshEnums.MessageType.SSH_MSG_GLOBAL_REQUEST.id().toInt(), payload.toByteArray()) } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/HostKeyUpdateTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/HostKeyUpdateTest.kt new file mode 100644 index 00000000..4f49354c --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/HostKeyUpdateTest.kt @@ -0,0 +1,287 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PingResult +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshSigning +import org.connectbot.sshlib.crypto.MldsaAlgorithm +import org.connectbot.sshlib.crypto.encodeSshString +import org.connectbot.sshlib.crypto.withMldsaTestProvider +import org.connectbot.sshlib.protocol.GlobalRequestHostkeysProve00Openssh +import org.connectbot.sshlib.transport.PipedTransport +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import java.security.KeyPair +import java.security.KeyPairGenerator +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +@ResourceLock("JCA_PROVIDERS") +internal class HostKeyUpdateTest { + private class Recorder : HostKeyVerifier { + val added = mutableListOf() + val removed = mutableListOf() + var blockPersistence: CompletableDeferred? = null + override suspend fun verify(key: PublicKey) = true + override suspend fun addKeys(keys: List) { + blockPersistence?.await() + added.addAll(keys) + } + override suspend fun removeKeys(keys: List) { + removed.addAll(keys) + } + } + + private suspend fun TestScope.fixture(recorder: Recorder, algorithms: String = "ssh-ed25519"): Pair { + val dispatcher = StandardTestDispatcher(testScheduler) + val (client, remote) = PipedTransport.create() + val server = FakeSshServer(remote, backgroundScope, dispatcher) + server.advertiseExtInfo = true + server.advertisePing = true + server.start(ignoreTransportErrors = true) + val connection = SshConnection( + transport = client, + hostKeyVerifier = recorder, + hostKeyAlgorithms = algorithms, + rekeyIntervalMs = Long.MAX_VALUE, + rekeyBytesLimit = Long.MAX_VALUE, + coroutineDispatcher = dispatcher, + ) + assertIs(connection.connect()) + val auth = async { connection.authenticatePassword("user", "pass") } + server.awaitUserauthRequest() + server.sendUserauthSuccess() + assertEquals(AuthResult.Success, auth.await()) + return connection to server + } + + private fun key(pair: KeyPair): PublicKey = SshSigning.encodePublicKey(pair).let { PublicKey(it.algorithmName, it.publicKeyBlob) } + private fun strings(blobs: List) = blobs.fold(byteArrayOf()) { result, blob -> result + encodeSshString(blob) } + + // Construct the OpenSSH signed payload independently of the production helper. + private fun proof(pair: KeyPair, sid: ByteArray, blob: ByteArray): ByteArray = SshSigning.signWithKeyPair( + key(pair).type, + pair, + encodeSshString("hostkeys-prove-00@openssh.com".toByteArray()) + encodeSshString(sid) + encodeSshString(blob), + ) + + @ParameterizedTest + @EnumSource(MldsaAlgorithm::class) + fun `proven PQC key replaces the authenticated classical key`(parameter: MldsaAlgorithm) = withMldsaTestProvider { provider -> + val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() + val newKey = key(pair) + runTest { + val recorder = Recorder() + val (connection, server) = fixture(recorder, "ssh-ed25519,${parameter.sshName}") + try { + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(newKey.encoded))) + val request = server.awaitGlobalRequest() + assertEquals("hostkeys-prove-00@openssh.com", request.requestName().value()) + assertEquals(1, request.wantReply()) + val requested = (request.requestSpecificFields() as GlobalRequestHostkeysProve00Openssh).hostkeys() + assertEquals(1, requested.size) + assertContentEquals(newKey.encoded, requested.single().data()) + assertTrue(recorder.added.isEmpty()) + assertTrue(recorder.removed.isEmpty()) + server.sendRequestSuccess(strings(listOf(proof(pair, server.sessionIdentifier, newKey.encoded)))) + runCurrent() + assertEquals(listOf(newKey), recorder.added) + assertEquals(listOf(PublicKey("ssh-ed25519", server.serverHostKeyBlob)), recorder.removed) + // A second announcement must not start another update. + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(newKey.encoded))) + runCurrent() + assertEquals(listOf(newKey), recorder.added) + assertIs(connection.ping()) + } finally { + connection.close() + } + } + } + + @ParameterizedTest + @ValueSource(strings = ["failure", "missing", "extra", "truncated", "wrong-session", "wrong-key", "reordered", "corrupt"]) + fun `bad proofs never add or retire keys`(mode: String) = runTest { + val pairs = List(2) { KeyPairGenerator.getInstance("Ed25519").generateKeyPair() } + val keys = pairs.map(::key) + val recorder = Recorder() + val (connection, server) = fixture(recorder) + try { + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(keys.map { it.encoded })) + server.awaitGlobalRequest() + val sid = server.sessionIdentifier + val signatures = pairs.zip(keys).map { (pair, key) -> proof(pair, sid, key.encoded) } + when (mode) { + "failure" -> server.sendRequestFailure() + "missing" -> server.sendRequestSuccess(strings(signatures.take(1))) + "extra" -> server.sendRequestSuccess(strings(signatures + signatures.first())) + "truncated" -> server.sendRequestSuccess(strings(signatures).dropLast(1).toByteArray()) + "wrong-session" -> server.sendRequestSuccess(strings(pairs.zip(keys).map { (pair, key) -> proof(pair, sid + 1, key.encoded) })) + "wrong-key" -> server.sendRequestSuccess(strings(pairs.zip(keys).map { (pair, key) -> proof(pair, sid, key.encoded + 1) })) + "reordered" -> server.sendRequestSuccess(strings(signatures.reversed())) + "corrupt" -> server.sendRequestSuccess(strings(signatures.map { it.copyOf().apply { this[lastIndex] = (last().toInt() xor 1).toByte() } })) + } + runCurrent() + assertTrue(recorder.added.isEmpty()) + assertTrue(recorder.removed.isEmpty()) + assertIs(connection.ping()) + } finally { + connection.close() + } + } + + @Test + fun `proof replies and concurrent forwarding replies retain FIFO ordering`() = runTest { + val recorder = Recorder() + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val newKey = key(pair) + val (connection, server) = fixture(recorder) + try { + val forward = async { connection.sendTcpipForwardRequest("localhost", 1234) } + assertEquals("tcpip-forward", server.awaitGlobalRequest().requestName().value()) + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(newKey.encoded))) + assertEquals("hostkeys-prove-00@openssh.com", server.awaitGlobalRequest().requestName().value()) + server.sendRequestSuccess() + assertEquals(1234, forward.await()) + server.sendRequestSuccess(strings(listOf(proof(pair, server.sessionIdentifier, newKey.encoded)))) + runCurrent() + assertEquals(listOf(newKey), recorder.added) + assertIs(connection.ping()) + } finally { + connection.close() + } + } + + @Test + fun `suspending persistence does not block packet routing or retire keys early`() = runTest { + val recorder = Recorder().apply { blockPersistence = CompletableDeferred() } + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val newKey = key(pair) + val (connection, server) = fixture(recorder) + try { + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(newKey.encoded))) + server.awaitGlobalRequest() + server.sendRequestSuccess(strings(listOf(proof(pair, server.sessionIdentifier, newKey.encoded)))) + runCurrent() + assertIs(connection.ping()) + assertTrue(recorder.removed.isEmpty()) + recorder.blockPersistence!!.complete(Unit) + runCurrent() + assertEquals(listOf(newKey), recorder.added) + assertEquals(1, recorder.removed.size) + } finally { + connection.close() + } + } + + @Test + fun `unknown keys are skipped and an advertised authenticated key is retained`() = runTest { + val recorder = Recorder() + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val newKey = key(pair) + val (connection, server) = fixture(recorder) + try { + val unknown = encodeSshString("future-pqc@example.com".toByteArray()) + byteArrayOf(1, 2, 3) + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(unknown, newKey.encoded, server.serverHostKeyBlob))) + val request = server.awaitGlobalRequest() + val requested = (request.requestSpecificFields() as GlobalRequestHostkeysProve00Openssh).hostkeys() + assertEquals(2, requested.size) + val signedInitial = server.signHostKeyData( + encodeSshString("hostkeys-prove-00@openssh.com".toByteArray()) + + encodeSshString(server.sessionIdentifier) + encodeSshString(server.serverHostKeyBlob), + ) + server.sendRequestSuccess(strings(listOf(proof(pair, server.sessionIdentifier, newKey.encoded), signedInitial))) + runCurrent() + assertEquals(listOf(newKey, PublicKey("ssh-ed25519", server.serverHostKeyBlob)), recorder.added) + assertTrue(recorder.removed.isEmpty()) + } finally { + connection.close() + } + } + + @ParameterizedTest + @ValueSource(strings = ["empty", "duplicate", "unknown", "disabled", "want-reply"]) + fun `unusable advertisements do not request proofs`(mode: String) = withMldsaTestProvider { provider -> + runTest { + val recorder = Recorder() + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val newKey = key(pair) + val (connection, server) = fixture(recorder) + try { + val blobs = when (mode) { + "empty" -> emptyList() + "duplicate" -> listOf(newKey.encoded, newKey.encoded) + "unknown" -> listOf(encodeSshString("unknown@example.com".toByteArray())) + "disabled" -> listOf(key(KeyPairGenerator.getInstance("ML-DSA-44", provider).generateKeyPair()).encoded) + else -> listOf(newKey.encoded) + } + server.sendGlobalRequest("hostkeys-00@openssh.com", mode == "want-reply", strings(blobs)) + runCurrent() + val forward = async { connection.sendTcpipForwardRequest("localhost", 1234) } + // If an unwanted proof request was sent, it would be first in the queue. + assertEquals("tcpip-forward", server.awaitGlobalRequest().requestName().value()) + server.sendRequestSuccess() + assertEquals(1234, forward.await()) + assertTrue(recorder.added.isEmpty()) + assertTrue(recorder.removed.isEmpty()) + } finally { + connection.close() + } + } + } + + @Test + fun `timed out proofs retain their reply slot without persisting keys`() = runTest { + val recorder = Recorder() + val pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() + val newKey = key(pair) + val (connection, server) = fixture(recorder) + try { + server.sendGlobalRequest("hostkeys-00@openssh.com", false, strings(listOf(newKey.encoded))) + server.awaitGlobalRequest() + advanceTimeBy(30_001) + runCurrent() + val forward = async { connection.sendTcpipForwardRequest("localhost", 1234) } + assertEquals("tcpip-forward", server.awaitGlobalRequest().requestName().value()) + server.sendRequestSuccess(strings(listOf(proof(pair, server.sessionIdentifier, newKey.encoded)))) + server.sendRequestSuccess() + assertEquals(1234, forward.await()) + assertTrue(recorder.added.isEmpty()) + assertTrue(recorder.removed.isEmpty()) + } finally { + connection.close() + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemNistConnectionConformanceTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemNistConnectionConformanceTest.kt new file mode 100644 index 00000000..ea4ac5b8 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemNistConnectionConformanceTest.kt @@ -0,0 +1,126 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.protocol.SshEnums +import org.connectbot.sshlib.transport.PipedTransport +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MlKemNistConnectionConformanceTest { + companion object { + @JvmStatic + fun malformedReplies(): List = listOf("mlkem768nistp256-sha256", "mlkem1024nistp384-sha384").flatMap { algorithm -> + listOf("short", "long", "zero-point", "compressed", "ciphertext").map { damage -> Arguments.of(algorithm, damage) } + } + } + + @ParameterizedTest + @ValueSource(strings = ["mlkem768nistp256-sha256", "mlkem1024nistp384-sha384"]) + fun `hybrids authenticate and rekey with independent BC server`(algorithm: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { kexAlgorithms = algorithm } + server.start() + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + assertEquals(algorithm, connection.connectionInfo?.kexAlgorithm) + assertTrue(connection.connectionInfo!!.isPostQuantumSecure) + val auth = backgroundScope.async(dispatcher) { connection.authenticatePassword("user", "pass") } + withTimeout(5000) { server.awaitUserauthRequest() } + server.sendUserauthSuccess() + assertIs(withTimeout(5000) { auth.await() }) + server.initiateRekey() + withTimeout(5000) { server.rekeyCount.first { it > 0 } } + val session = backgroundScope.async(dispatcher) { connection.openSessionChannel() } + val open = withTimeout(5000) { server.awaitChannelOpen() } + server.sendChannelOpenConfirmation(open.senderChannel().toInt(), 42) + assertNotNull(withTimeout(5000) { session.await() }).close() + } finally { + connection.close() + } + } + + @ParameterizedTest + @MethodSource("malformedReplies") + fun `malformed replies disconnect before host verification`(algorithm: String, damage: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val ciphertextSize = if (algorithm == "mlkem1024nistp384-sha384") 1568 else 1088 + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { + kexAlgorithms = algorithm + transformKexServerPublic = { reply -> + when (damage) { + "short" -> reply.copyOf(reply.size - 1) + "long" -> reply.copyOf(reply.size + 1) + "zero-point" -> reply.copyOf().also { it.fill(0, ciphertextSize, it.size) } + "compressed" -> reply.copyOf().also { it[ciphertextSize] = 2 } + "ciphertext" -> reply.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + else -> error("Unknown damage") + } + } + } + server.start(ignoreTransportErrors = true) + var verifierCalled = false + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean { + verifierCalled = true + return true + } + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + val disconnect = withTimeout(5000) { server.awaitDisconnect() } + assertEquals(SshEnums.DisconnectReason.SSH_DISCONNECT_KEY_EXCHANGE_FAILED, disconnect.reasonCode()) + assertFalse(verifierCalled) + } finally { + connection.close() + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemPureConnectionConformanceTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemPureConnectionConformanceTest.kt new file mode 100644 index 00000000..d7b2b444 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/MlKemPureConnectionConformanceTest.kt @@ -0,0 +1,123 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.protocol.SshEnums +import org.connectbot.sshlib.transport.PipedTransport +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MlKemPureConnectionConformanceTest { + companion object { + @JvmStatic + fun malformedReplies(): List = listOf("mlkem768-sha256", "mlkem1024-sha384").flatMap { algorithm -> + listOf("short", "long", "ciphertext").map { damage -> Arguments.of(algorithm, damage) } + } + } + + @ParameterizedTest + @ValueSource(strings = ["mlkem768-sha256", "mlkem1024-sha384"]) + fun `pure ML-KEM exchanges authenticate and rekey with independent BC server`(algorithm: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { kexAlgorithms = algorithm } + server.start() + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + assertEquals(algorithm, connection.connectionInfo?.kexAlgorithm) + assertTrue(connection.connectionInfo!!.isPostQuantumSecure) + val auth = backgroundScope.async(dispatcher) { connection.authenticatePassword("user", "pass") } + withTimeout(5000) { server.awaitUserauthRequest() } + server.sendUserauthSuccess() + assertIs(withTimeout(5000) { auth.await() }) + server.initiateRekey() + withTimeout(5000) { server.rekeyCount.first { it > 0 } } + val session = backgroundScope.async(dispatcher) { connection.openSessionChannel() } + val open = withTimeout(5000) { server.awaitChannelOpen() } + server.sendChannelOpenConfirmation(open.senderChannel().toInt(), 42) + assertNotNull(withTimeout(5000) { session.await() }).close() + } finally { + connection.close() + } + } + + @ParameterizedTest + @MethodSource("malformedReplies") + fun `malformed replies disconnect before host verification`(algorithm: String, damage: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { + kexAlgorithms = algorithm + transformKexServerPublic = { reply -> + when (damage) { + "short" -> reply.copyOf(reply.size - 1) + "long" -> reply.copyOf(reply.size + 1) + "ciphertext" -> reply.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + else -> error("Unknown damage") + } + } + } + server.start(ignoreTransportErrors = true) + var verifierCalled = false + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean { + verifierCalled = true + return true + } + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + val disconnect = withTimeout(5000) { server.awaitDisconnect() } + assertEquals(SshEnums.DisconnectReason.SSH_DISCONNECT_KEY_EXCHANGE_FAILED, disconnect.reasonCode()) + assertFalse(verifierCalled) + } finally { + connection.close() + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SntrupConnectionConformanceTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SntrupConnectionConformanceTest.kt new file mode 100644 index 00000000..cc5cf263 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SntrupConnectionConformanceTest.kt @@ -0,0 +1,134 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.client + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.connectbot.sshlib.AuthResult +import org.connectbot.sshlib.ConnectResult +import org.connectbot.sshlib.HostKeyVerifier +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.protocol.SshEnums +import org.connectbot.sshlib.transport.PipedTransport +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SntrupConnectionConformanceTest { + companion object { + @JvmStatic + fun malformedReplies(): List = listOf("sntrup761x25519-sha512", "sntrup761x25519-sha512@openssh.com").flatMap { algorithm -> + listOf("short", "long", "zero-point", "low-order", "ciphertext").map { damage -> Arguments.of(algorithm, damage) } + } + } + + @ParameterizedTest + @ValueSource(strings = ["sntrup761x25519-sha512", "sntrup761x25519-sha512@openssh.com"]) + fun `hybrids authenticate and rekey with independent BC server`(algorithm: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { kexAlgorithms = algorithm } + server.start() + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = true + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + assertEquals(algorithm, connection.connectionInfo?.kexAlgorithm) + assertTrue(connection.connectionInfo!!.isPostQuantumSecure) + val auth = backgroundScope.async(dispatcher) { connection.authenticatePassword("user", "pass") } + withTimeout(5000) { server.awaitUserauthRequest() } + server.sendUserauthSuccess() + assertIs(withTimeout(5000) { auth.await() }) + server.initiateRekey() + withTimeout(5000) { server.rekeyCount.first { it > 0 } } + val session = backgroundScope.async(dispatcher) { connection.openSessionChannel() } + val open = withTimeout(5000) { server.awaitChannelOpen() } + server.sendChannelOpenConfirmation(open.senderChannel().toInt(), 42) + assertNotNull(withTimeout(5000) { session.await() }).close() + } finally { + connection.close() + } + } + + @ParameterizedTest + @MethodSource("malformedReplies") + fun `malformed replies disconnect before host verification`(algorithm: String, damage: String) = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val (clientTransport, serverTransport) = PipedTransport.create() + val ciphertextSize = 1039 + val server = FakeSshServer(serverTransport, backgroundScope, dispatcher).apply { + kexAlgorithms = algorithm + transformKexServerPublic = { reply -> + when (damage) { + "short" -> reply.copyOf(reply.size - 1) + + "long" -> reply.copyOf(reply.size + 1) + + "zero-point" -> reply.copyOf().also { it.fill(0, ciphertextSize, it.size) } + + "low-order" -> reply.copyOf().also { + it.fill(0, ciphertextSize, it.size) + it[ciphertextSize] = 1 + } + + "ciphertext" -> reply.copyOf().also { it[0] = (it[0].toInt() xor 1).toByte() } + + else -> error("Unknown damage") + } + } + } + server.start(ignoreTransportErrors = true) + var verifierCalled = false + val connection = SshConnection( + transport = clientTransport, + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean { + verifierCalled = true + return true + } + }, + kexAlgorithms = algorithm, + coroutineDispatcher = dispatcher, + ) + try { + val connect = backgroundScope.async(dispatcher) { connection.connect() } + assertIs(withTimeout(5000) { connect.await() }) + val disconnect = withTimeout(5000) { server.awaitDisconnect() } + assertEquals(SshEnums.DisconnectReason.SSH_DISCONNECT_KEY_EXCHANGE_FAILED, disconnect.reasonCode()) + assertFalse(verifierCalled) + } finally { + connection.close() + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt index ac519ce3..9de288c8 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/client/SshClientIntegrationTest.kt @@ -18,6 +18,7 @@ package org.connectbot.sshlib.client import io.kaitai.struct.ByteBufferKaitaiStream +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -106,6 +107,8 @@ class SshClientIntegrationTest { ) .withExposedPorts(22) .withLogConsumer(logConsumer) + // Some supported algorithms, including ML-KEM/P-256, are disabled by default. + .withCommand("/usr/sbin/sshd", "-D", "-e", "-o", "KexAlgorithms=+${kexAlgorithms().joinToString(",")}") .waitingFor( Wait.forLogMessage(".*Server listening.*", 1), ) @@ -139,6 +142,9 @@ class SshClientIntegrationTest { @JvmStatic fun kexAlgorithms() = listOf( + "mlkem768nistp256-sha256", + "sntrup761x25519-sha512", + "sntrup761x25519-sha512@openssh.com", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", @@ -206,6 +212,63 @@ class SshClientIntegrationTest { assertTrue(SignatureVerifier.verifyWithKeyType(importedPublic.publicKeyBlob, signature, message)) } + @Test + @ResourceLock("JCA_PROVIDERS") + fun `OpenSSH proves mixed host keys and bootstraps a pinned PQC connection`() = withMldsaTestProvider { + runBlocking { + val learned = CompletableDeferred>() + var authenticatedKey: PublicKey? = null + val client = SshClient( + SshClientConfig { + host = opensshContainer.host + port = opensshContainer.getMappedPort(22) + hostKeyAlgorithms = "ssh-ed25519,ssh-mldsa44-ed25519,rsa-sha2-512,ecdsa-sha2-nistp256" + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean { + authenticatedKey = key + return key.type == "ssh-ed25519" + } + override suspend fun addKeys(keys: List) { + learned.complete(keys.toList()) + } + override suspend fun removeKeys(keys: List) { + kotlin.test.fail("Advertised authenticated key must be retained") + } + } + }, + ) + val pinned: List + try { + assertIs(client.connect()) + assertIs(client.authenticatePassword(USERNAME, PASSWORD)) + pinned = withTimeout(30_000) { learned.await() } + assertTrue(pinned.any { it.type == "ssh-mldsa44-ed25519" }) + assertTrue(pinned.any { it.type == "ssh-rsa" }) + assertTrue(pinned.contains(authenticatedKey)) + } finally { + client.disconnect() + } + + val pqcClient = SshClient( + SshClientConfig { + host = opensshContainer.host + port = opensshContainer.getMappedPort(22) + hostKeyAlgorithms = "ssh-mldsa44-ed25519" + hostKeyVerifier = object : HostKeyVerifier { + override suspend fun verify(key: PublicKey): Boolean = key in pinned + } + }, + ) + try { + assertIs(pqcClient.connect()) + assertEquals("ssh-mldsa44-ed25519", pqcClient.connectionInfo?.serverHostKeyAlgorithm) + assertEquals(AuthResult.Success, pqcClient.authenticatePassword(USERNAME, PASSWORD)) + } finally { + pqcClient.disconnect() + } + } + } + @Test @ResourceLock("JCA_PROVIDERS") fun `composite host and client signatures interoperate with OpenSSH and ML-KEM rekey`() = withMldsaTestProvider { provider -> @@ -458,10 +521,12 @@ class SshClientIntegrationTest { this.hostKeyVerifier = acceptAllVerifier this.kexAlgorithms = "$algorithm,kex-strict-c-v00@openssh.com" } - val client = BlockingSshClient(config) + val asyncClient = SshClient(config) + val client = BlockingSshClient(asyncClient) try { client.connect() + assertEquals(algorithm, asyncClient.connectionInfo?.kexAlgorithm) client.authenticatePassword(USERNAME, PASSWORD) val session = client.openSession() diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeyProofTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeyProofTest.kt new file mode 100644 index 00000000..cea68ced --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeyProofTest.kt @@ -0,0 +1,68 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.PublicKey +import org.connectbot.sshlib.SshSigning +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.security.KeyPairGenerator +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ResourceLock("JCA_PROVIDERS") +internal class HostKeyProofTest { + private val sessionId = ByteArray(32) { it.toByte() } + + @Test + fun `RSA proofs use the negotiated SHA2 algorithm and cannot fall back to SHA1`() { + val pair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val key = SshSigning.encodePublicKey(pair).let { PublicKey(it.algorithmName, it.publicKeyBlob) } + val algorithms = setOf("rsa-sha2-256", "rsa-sha2-512", "ssh-rsa") + for (algorithm in algorithms) { + val signature = SshSigning.signWithKeyPair(algorithm, pair, hostKeyProofData(sessionId, key.encoded)) + val reply = encodeSshString(signature) + for (negotiated in algorithms + "ssh-ed25519") { + val expected = algorithm != "ssh-rsa" && (negotiated == algorithm || negotiated == "ssh-ed25519") + val valid = verifyHostKeyProofs(listOf(key), reply, sessionId, negotiated, algorithms) + if (expected) assertTrue(valid) else assertFalse(valid) + } + assertFalse(verifyHostKeyProofs(listOf(key), reply, sessionId, "ssh-ed25519", emptySet())) + } + } + + @ParameterizedTest + @EnumSource(MldsaAlgorithm::class) + fun `PQC proof validation rejects tampering and trailing bytes`(parameter: MldsaAlgorithm) = withMldsaTestProvider { provider -> + val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() + val key = SshSigning.encodePublicKey(pair).let { PublicKey(it.algorithmName, it.publicKeyBlob) } + val signature = SshSigning.signWithKeyPair(parameter.sshName, pair, hostKeyProofData(sessionId, key.encoded)) + val allowed = setOf(parameter.sshName) + fun verify(keys: List, proof: ByteArray) = verifyHostKeyProofs(keys, proof, sessionId, "ssh-ed25519", allowed) + assertTrue(verify(listOf(key), encodeSshString(signature))) + assertFalse(verify(listOf(key), encodeSshString(signature + 0))) + val trailingKey = key.copy(encoded = key.encoded + 0) + val trailingKeySignature = SshSigning.signWithKeyPair(parameter.sshName, pair, hostKeyProofData(sessionId, trailingKey.encoded)) + assertFalse(verify(listOf(trailingKey), encodeSshString(trailingKeySignature))) + val damaged = signature.copyOf().apply { this[lastIndex] = (last().toInt() xor 1).toByte() } + assertFalse(verify(listOf(key), encodeSshString(damaged))) + assertFalse(verifyHostKeyProofs(listOf(key), encodeSshString(signature), sessionId, "ssh-ed25519", emptySet())) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeysProtocolTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeysProtocolTest.kt new file mode 100644 index 00000000..dd73bb88 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/HostKeysProtocolTest.kt @@ -0,0 +1,115 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.SshSigning +import org.connectbot.sshlib.protocol.GlobalRequestHostkeys00Openssh +import org.connectbot.sshlib.protocol.GlobalRequestHostkeysProve00Openssh +import org.connectbot.sshlib.protocol.GlobalRequestResponseHostkeysProve00Openssh +import org.connectbot.sshlib.protocol.SshMsgGlobalRequest +import org.connectbot.sshlib.protocol.toByteArray +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import java.security.KeyPairGenerator +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ResourceLock("JCA_PROVIDERS") +internal class HostKeysProtocolTest { + private fun request(name: String, wantReply: Boolean, body: ByteArray): ByteArray = encodeSshString(name.toByteArray()) + byteArrayOf(if (wantReply) 1 else 0) + body + + private fun parseRequest(bytes: ByteArray) = SshMsgGlobalRequest(ByteBufferKaitaiStream(bytes)).apply { _read() } + + @Test + fun `mixed classical and PQC host key announcements and proofs round trip`() = withMldsaTestProvider { provider -> + val parameters = MldsaAlgorithm.entries + val pairs = listOf(KeyPairGenerator.getInstance("Ed25519").generateKeyPair()) + + parameters.map { KeyPairGenerator.getInstance(it.jcaName, provider).generateKeyPair() } + val keys = pairs.map { SshSigning.encodePublicKey(it) } + val keyList = keys.fold(byteArrayOf()) { bytes, key -> bytes + encodeSshString(key.publicKeyBlob) } + val announcementBytes = request("hostkeys-00@openssh.com", false, keyList) + val announcement = parseRequest(announcementBytes) + val announcedKeys = (announcement.requestSpecificFields() as GlobalRequestHostkeys00Openssh).hostkeys() + assertEquals(keys.size, announcedKeys.size) + keys.zip(announcedKeys).forEach { (key, parsed) -> assertContentEquals(key.publicKeyBlob, parsed.data()) } + assertContentEquals(announcementBytes, announcement.toByteArray(announcementBytes.size.toLong())) + + val proofRequestBytes = request("hostkeys-prove-00@openssh.com", true, keyList) + val proofRequest = parseRequest(proofRequestBytes) + val requestedKeys = (proofRequest.requestSpecificFields() as GlobalRequestHostkeysProve00Openssh).hostkeys() + assertEquals(keys.size, requestedKeys.size) + keys.zip(requestedKeys).forEach { (key, parsed) -> assertContentEquals(key.publicKeyBlob, parsed.data()) } + assertContentEquals(proofRequestBytes, proofRequest.toByteArray(proofRequestBytes.size.toLong())) + + val sessionId = ByteArray(32) { it.toByte() } + + // OpenSSH signs three SSH strings, including the extension name as a domain separator. + fun proofData(session: ByteArray, key: ByteArray) = encodeSshString("hostkeys-prove-00@openssh.com".toByteArray()) + + encodeSshString(session) + encodeSshString(key) + val signatures = pairs.zip(keys).map { (pair, key) -> + SshSigning.signWithKeyPair(key.algorithmName, pair, proofData(sessionId, key.publicKeyBlob)) + } + val replyBytes = signatures.fold(byteArrayOf()) { bytes, signature -> bytes + encodeSshString(signature) } + val reply = GlobalRequestResponseHostkeysProve00Openssh(ByteBufferKaitaiStream(replyBytes)).apply { _read() } + assertEquals(keys.size, reply.signatures().size) + assertContentEquals(replyBytes, reply.toByteArray(replyBytes.size.toLong())) + keys.zip(reply.signatures()).forEachIndexed { index, (key, signature) -> + assertContentEquals(signatures[index], signature.data()) + assertTrue(SignatureVerifier.verifyWithKeyType(key.publicKeyBlob, signature.data(), proofData(sessionId, key.publicKeyBlob))) + assertFalse(SignatureVerifier.verifyWithKeyType(key.publicKeyBlob, signature.data(), proofData(sessionId + 1, key.publicKeyBlob))) + val otherKey = keys[(index + 1) % keys.size].publicKeyBlob + assertFalse(SignatureVerifier.verifyWithKeyType(key.publicKeyBlob, signature.data(), proofData(sessionId, otherKey))) + } + } + + @Test + fun `unknown and large key blobs preserve framing`() { + val unknown = encodeSshString("future-pqc@example.com".toByteArray()) + ByteArray(70000) { it.toByte() } + val tail = encodeSshString("another-key@example.com".toByteArray()) + val body = encodeSshString(unknown) + encodeSshString(tail) + for (name in listOf("hostkeys-00@openssh.com", "hostkeys-prove-00@openssh.com")) { + val bytes = request(name, name.contains("prove"), body) + val parsed = parseRequest(bytes) + val keys = when (val fields = parsed.requestSpecificFields()) { + is GlobalRequestHostkeys00Openssh -> fields.hostkeys() + is GlobalRequestHostkeysProve00Openssh -> fields.hostkeys() + else -> error("Unexpected request") + } + assertEquals(2, keys.size) + assertContentEquals(unknown, keys[0].data()) + assertContentEquals(tail, keys[1].data()) + assertContentEquals(bytes, parsed.toByteArray(bytes.size.toLong())) + } + } + + @Test + fun `truncated host key and signature lists are rejected`() { + for (body in listOf(byteArrayOf(0), byteArrayOf(0, 0, 0, 2, 1), byteArrayOf(-1, -1, -1, -1))) { + for (name in listOf("hostkeys-00@openssh.com", "hostkeys-prove-00@openssh.com")) { + assertFailsWith { parseRequest(request(name, false, body)) } + } + assertFailsWith { + GlobalRequestResponseHostkeysProve00Openssh(ByteBufferKaitaiStream(body))._read() + } + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProviderTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProviderTest.kt index cce71d60..7d3eefdd 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProviderTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/KyberKotlinMlKemProviderTest.kt @@ -1,6 +1,6 @@ /* * ConnectBot SSH Library - * Copyright 2025 Kenny Root + * Copyright 2025-2026 Kenny Root * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,16 @@ package org.connectbot.sshlib.crypto +import org.bouncycastle.crypto.kems.MLKEMExtractor +import org.bouncycastle.crypto.kems.MLKEMGenerator +import org.bouncycastle.crypto.params.MLKEMParameters +import org.bouncycastle.crypto.params.MLKEMPrivateKeyParameters +import org.bouncycastle.crypto.params.MLKEMPublicKeyParameters +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import java.io.IOException +import java.security.SecureRandom +import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -25,7 +34,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNotNull -class KyberKotlinMlKemProviderTest { +internal class KyberKotlinMlKemProviderTest { private val provider = KyberKotlinMlKemProvider() @Test @@ -80,4 +89,53 @@ class KyberKotlinMlKemProviderTest { } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + @ParameterizedTest + @EnumSource(MlKemParameterSet::class) + fun `both parameter sets interoperate with BC`(parameters: MlKemParameterSet) { + val kotlin = KyberKotlinMlKemProvider(parameters) + val keys = kotlin.generateKeyPair() + assertEquals(if (parameters == MlKemParameterSet.ML_KEM_768) 1184 else 1568, keys.publicKey.size) + assertEquals(if (parameters == MlKemParameterSet.ML_KEM_768) 2400 else 3168, keys.privateKey.size) + val bcParameters = if (parameters == MlKemParameterSet.ML_KEM_768) MLKEMParameters.ml_kem_768 else MLKEMParameters.ml_kem_1024 + val encapsulated = MLKEMGenerator(SecureRandom()).generateEncapsulated(MLKEMPublicKeyParameters(bcParameters, keys.publicKey)) + try { + assertContentEquals(encapsulated.secret, kotlin.decapsulate(keys.privateKey, encapsulated.encapsulation)) + } finally { + keys.privateKey.fill(0) + encapsulated.destroy() + } + } + + @ParameterizedTest + @EnumSource(MlKemParameterSet::class) + fun `arbitrary ciphertexts match BC implicit rejection`(parameters: MlKemParameterSet) { + val provider = KyberKotlinMlKemProvider(parameters) + val keys = provider.generateKeyPair() + val bcParameters = if (parameters == MlKemParameterSet.ML_KEM_768) MLKEMParameters.ml_kem_768 else MLKEMParameters.ml_kem_1024 + val bc = MLKEMExtractor(MLKEMPrivateKeyParameters(bcParameters, keys.privateKey)) + try { + val random = Random(203) + val inputs = listOf(ByteArray(parameters.ciphertextSize), ByteArray(parameters.ciphertextSize) { 0xff.toByte() }) + + List(32) { random.nextBytes(parameters.ciphertextSize) } + for (ciphertext in inputs) { + assertContentEquals(bc.extractSecret(ciphertext), provider.decapsulate(keys.privateKey, ciphertext)) + } + } finally { + keys.privateKey.fill(0) + } + } + + @Test + fun `parameter set mismatch is rejected rather than inferred from input length`() { + val large = KyberKotlinMlKemProvider(MlKemParameterSet.ML_KEM_1024) + val keys = large.generateKeyPair() + try { + val ciphertext = large.encapsulate(keys.publicKey).ciphertext + assertFailsWith { provider.encapsulate(keys.publicKey) } + assertFailsWith { provider.decapsulate(keys.privateKey, ciphertext) } + } finally { + keys.privateKey.fill(0) + } + } } diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchangeTest.kt new file mode 100644 index 00000000..b97acf10 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemKeyExchangeTest.kt @@ -0,0 +1,84 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.bouncycastle.crypto.kems.MLKEMGenerator +import org.bouncycastle.crypto.params.MLKEMParameters +import org.bouncycastle.crypto.params.MLKEMPublicKeyParameters +import org.connectbot.sshlib.SshException +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.security.SecureRandom +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +internal class MlKemKeyExchangeTest { + @ParameterizedTest + @EnumSource(MlKemParameterSet::class) + fun `pure ML-KEM agrees with independent BC and stays default off`(parameter: MlKemParameterSet) { + val client = MlKemKeyExchange(parameter) + val pub = client.generateClientKeys() + val p = if (parameter == MlKemParameterSet.ML_KEM_768) MLKEMParameters.ml_kem_768 else MLKEMParameters.ml_kem_1024 + val server = MLKEMGenerator(SecureRandom()).generateEncapsulated(MLKEMPublicKeyParameters(p, pub)) + try { + assertContentEquals(encodeSshString(server.secret), client.computeSharedSecret(server.encapsulation)) + for (size in listOf(0, server.encapsulation.size - 1, server.encapsulation.size + 1)) assertFailsWith { client.computeSharedSecret(ByteArray(size)) } + val name = if (parameter == MlKemParameterSet.ML_KEM_768) "mlkem768-sha256" else "mlkem1024-sha384" + val entry = assertNotNull(KexEntry.fromSshName(name)) + assertTrue(entry.isPostQuantum) + assertFalse(entry in KexEntry.defaults) + assertFalse(name in KexEntry.defaultString) + assertEquals(if (parameter == MlKemParameterSet.ML_KEM_768) "SHA-256" else "SHA-384", client.hashAlgorithm) + } finally { + client.zeroize() + server.destroy() + } + assertFailsWith { client.computeSharedSecret(ByteArray(parameter.ciphertextSize)) } + } + + @ParameterizedTest + @EnumSource(MlKemParameterSet::class) + fun `secret remains a 32-byte string including zero or high leading bytes and is erased`(parameter: MlKemParameterSet) { + for (first in listOf(0, 128)) { + val secret = ByteArray(32) { it.toByte() }.also { it[0] = first.toByte() } + val retained = ByteArray(parameter.privateKeySize) { 9 } + var decapsulated = false + val provider = object : MlKemProvider by KyberKotlinMlKemProvider(parameter) { + override fun generateKeyPair() = MlKemKeyPair(ByteArray(parameter.publicKeySize), retained) + override fun decapsulate(privateKey: ByteArray, ciphertext: ByteArray): ByteArray { + decapsulated = true + return secret + } + } + val client = MlKemKeyExchange(parameter, provider) + client.generateClientKeys() + assertFailsWith { client.computeSharedSecret(ByteArray(1)) } + assertFalse(decapsulated) + val expected = encodeSshString(secret.copyOf()) + assertContentEquals(expected, client.computeSharedSecret(ByteArray(parameter.ciphertextSize))) + assertTrue(secret.all { it == 0.toByte() }) + client.generateClientKeys() + assertTrue(retained.all { it == 0.toByte() }) + client.zeroize() + } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchangeTest.kt new file mode 100644 index 00000000..d77ab44e --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemNistHybridKeyExchangeTest.kt @@ -0,0 +1,174 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.connectbot.sshlib.SshException +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.security.AlgorithmParameters +import java.security.spec.ECFieldFp +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.util.HexFormat +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class MlKemNistHybridKeyExchangeTest { + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `Kotlin client agrees with independent BC and JCA server`(parameters: MlKemNistParameters) { + val kex = MlKemNistHybridKeyExchange(parameters, KyberKotlinMlKemProvider(parameters.mlKem)) + try { + val clientInit = kex.generateClientKeys() + val is384 = parameters == MlKemNistParameters.NISTP384 + assertEquals(if (is384) 1665 else 1249, clientInit.size) + val server = MlKemTestServer.reply(clientInit, is384) + assertEquals(if (is384) 1665 else 1153, server.reply.size) + assertContentEquals(server.sharedSecret, kex.computeSharedSecret(server.reply)) + assertEquals(if (is384) "SHA-384" else "SHA-256", kex.hashAlgorithm) + } finally { + kex.zeroize() + } + assertFailsWith { kex.computeSharedSecret(ByteArray(0)) } + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `wrong reply size fails before decapsulation`(parameters: MlKemNistParameters) { + var decapsulated = false + val provider = object : MlKemProvider by KyberKotlinMlKemProvider(parameters.mlKem) { + override fun decapsulate(privateKey: ByteArray, ciphertext: ByteArray): ByteArray { + decapsulated = true + return ByteArray(32) + } + } + val kex = MlKemNistHybridKeyExchange(parameters, provider) + try { + kex.generateClientKeys() + val size = if (parameters == MlKemNistParameters.NISTP384) 1665 else 1153 + for (length in listOf(0, size - 1, size + 1)) { + assertFailsWith { kex.computeSharedSecret(ByteArray(length)) } + } + assertFalse(decapsulated) + } finally { + kex.zeroize() + } + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `infinity compressed off-curve and out-of-range points fail before decapsulation`(parameters: MlKemNistParameters) { + var decapsulated = false + val provider = object : MlKemProvider by KyberKotlinMlKemProvider(parameters.mlKem) { + override fun decapsulate(privateKey: ByteArray, ciphertext: ByteArray): ByteArray { + decapsulated = true + return ByteArray(32) + } + } + val kex = MlKemNistHybridKeyExchange(parameters, provider) + try { + kex.generateClientKeys() + val size = parameters.mlKem.ciphertextSize + 1 + 2 * parameters.fieldSize + for (tag in listOf(0, 2, 4)) { + val reply = ByteArray(size).also { it[parameters.mlKem.ciphertextSize] = tag.toByte() } + assertFailsWith { kex.computeSharedSecret(reply) } + } + val algorithm = AlgorithmParameters.getInstance("EC") + algorithm.init(ECGenParameterSpec(if (parameters.fieldSize == 32) "secp256r1" else "secp384r1")) + val spec = algorithm.getParameterSpec(ECParameterSpec::class.java) + val p = (spec.curve.field as ECFieldFp).p.toByteArray().takeLast(parameters.fieldSize).toByteArray() + val reply = ByteArray(size).also { + it[parameters.mlKem.ciphertextSize] = 4 + p.copyInto(it, parameters.mlKem.ciphertextSize + 1) + } + assertFailsWith { kex.computeSharedSecret(reply) } + assertFalse(decapsulated) + } finally { + kex.zeroize() + } + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `fixed width combiner matches independent fixtures with zero and high first byte`(parameters: MlKemNistParameters) { + val kex = MlKemNistHybridKeyExchange(parameters) + val pq = ByteArray(32) { (it + 10).toByte() } + for (first in listOf(0, 128)) { + val ec = ByteArray(parameters.fieldSize) { it.toByte() }.also { it[0] = first.toByte() } + val fixture = when (parameters to first) { + MlKemNistParameters.NISTP256 to 0 -> "0000002085f723f0303811e5b5077b130289be04c47df6e505d5fdbec17caef278d49768" + MlKemNistParameters.NISTP256 to 128 -> "000000201932f7ce81dfad66450d3b020b995f182c09ae8194e74e8da09d93fcbdd4a829" + MlKemNistParameters.NISTP384 to 0 -> "0000003014fbb275288b019e61881b4b8e0d0d209e898afc0487475439b20601cb0be03094c76d2fd6f82b78ee186ce12d8e85eb" + MlKemNistParameters.NISTP384 to 128 -> "00000030e526285afafeb1895b69e9052b14ac7c1c08cf24178d445a8199a7f2669ea18f1011cf24f54c5ee164ec423619240a8f" + else -> error("Missing fixture") + } + assertContentEquals(HexFormat.of().parseHex(fixture), kex.combineSharedSecrets(pq, ec)) + } + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `combiner rejects variable-width secrets and zero ECDH output`(parameters: MlKemNistParameters) { + val kex = MlKemNistHybridKeyExchange(parameters) + val ec = ByteArray(parameters.fieldSize) { 1 } + for (size in listOf(0, 31, 33)) { + assertFailsWith { kex.combineSharedSecrets(ByteArray(size), ec) } + } + for (size in listOf(0, parameters.fieldSize - 1, parameters.fieldSize + 1)) { + assertFailsWith { kex.combineSharedSecrets(ByteArray(32), ByteArray(size) { 1 }) } + } + assertFailsWith { kex.combineSharedSecrets(ByteArray(32), ByteArray(parameters.fieldSize)) } + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `regeneration and cleanup erase retained ML-KEM private keys`(parameters: MlKemNistParameters) { + val retained = mutableListOf() + val provider = object : MlKemProvider by KyberKotlinMlKemProvider(parameters.mlKem) { + override fun generateKeyPair(): MlKemKeyPair = MlKemKeyPair( + ByteArray(parameters.mlKem.publicKeySize), + ByteArray(parameters.mlKem.privateKeySize) { 7 }.also { retained.add(it) }, + ) + } + val kex = MlKemNistHybridKeyExchange(parameters, provider) + assertFailsWith { kex.computeSharedSecret(ByteArray(0)) } + kex.generateClientKeys() + kex.generateClientKeys() + assertTrue(retained[0].all { it == 0.toByte() }) + assertTrue(retained[1].any { it != 0.toByte() }) + kex.zeroize() + kex.zeroize() + assertTrue(retained[1].all { it == 0.toByte() }) + } + + @ParameterizedTest + @EnumSource(MlKemNistParameters::class) + fun `malformed generated ML-KEM key is rejected and erased`(parameters: MlKemNistParameters) { + val retained = ByteArray(32) { 7 } + val provider = object : MlKemProvider by KyberKotlinMlKemProvider(parameters.mlKem) { + override fun generateKeyPair(): MlKemKeyPair = MlKemKeyPair(ByteArray(1), retained) + } + val kex = MlKemNistHybridKeyExchange(parameters, provider) + assertFailsWith { kex.generateClientKeys() } + assertTrue(retained.all { it == 0.toByte() }) + assertFailsWith { kex.computeSharedSecret(ByteArray(0)) } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemTestServer.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemTestServer.kt new file mode 100644 index 00000000..b1517c60 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MlKemTestServer.kt @@ -0,0 +1,93 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.bouncycastle.crypto.kems.MLKEMGenerator +import org.bouncycastle.crypto.params.MLKEMParameters +import org.bouncycastle.crypto.params.MLKEMPublicKeyParameters +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import javax.crypto.KeyAgreement + +internal data class MlKemServerExchange(val reply: ByteArray, val sharedSecret: ByteArray) + +/** Independent RFC 10042 server combiner: BC ML-KEM and direct JCA ECDH, no client KEX code. */ +internal object MlKemTestServer { + fun pureReply(clientInit: ByteArray, mlKem1024: Boolean): MlKemServerExchange { + val kem = if (mlKem1024) MLKEMParameters.ml_kem_1024 else MLKEMParameters.ml_kem_768 + val result = MLKEMGenerator(SecureRandom()).generateEncapsulated(MLKEMPublicKeyParameters(kem, clientInit)) + return try { + MlKemServerExchange(result.encapsulation, ByteBuffer.allocate(4).putInt(32).array() + result.secret) + } finally { + result.destroy() + } + } + + fun reply(clientInit: ByteArray, nist384: Boolean): MlKemServerExchange { + val fieldSize = if (nist384) 48 else 32 + val pqSize = if (nist384) 1568 else 1184 + val curve = if (nist384) "secp384r1" else "secp256r1" + val hash = if (nist384) "SHA-384" else "SHA-256" + val kem = if (nist384) MLKEMParameters.ml_kem_1024 else MLKEMParameters.ml_kem_768 + check(clientInit.size == pqSize + 1 + 2 * fieldSize) + check(clientInit[pqSize] == 4.toByte()) + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec(curve)) + val server = generator.generateKeyPair() + val pub = server.public as ECPublicKey + val clientPoint = ECPoint( + BigInteger(1, clientInit, pqSize + 1, fieldSize), + BigInteger(1, clientInit, pqSize + 1 + fieldSize, fieldSize), + ) + val clientKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(clientPoint, pub.params)) + val agreement = KeyAgreement.getInstance("ECDH") + agreement.init(server.private) + agreement.doPhase(clientKey, true) + val classical = agreement.generateSecret() + val encapsulated = MLKEMGenerator(SecureRandom()).generateEncapsulated( + MLKEMPublicKeyParameters(kem, clientInit.copyOfRange(0, pqSize)), + ) + try { + val publicKey = byteArrayOf(4) + coordinate(pub.w.affineX, fieldSize) + coordinate(pub.w.affineY, fieldSize) + val digest = MessageDigest.getInstance(hash).digest(encapsulated.secret + classical) + return MlKemServerExchange( + encapsulated.encapsulation + publicKey, + ByteBuffer.allocate(4).putInt(digest.size).array() + digest, + ) + } finally { + classical.fill(0) + encapsulated.destroy() + } + } + + private fun coordinate(value: BigInteger, width: Int): ByteArray { + val raw = value.toByteArray() + val result = ByteArray(width) + val count = minOf(raw.size, width) + System.arraycopy(raw, raw.size - count, result, width - count, count) + return result + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384AdversarialTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384AdversarialTest.kt new file mode 100644 index 00000000..d48a041d --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384AdversarialTest.kt @@ -0,0 +1,92 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.connectbot.sshlib.SshSigning +import org.connectbot.sshlib.protocol.SshMldsaP384SignatureBlob +import org.connectbot.sshlib.protocol.SshSignature +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import java.math.BigInteger +import java.nio.ByteBuffer +import java.security.KeyPairGenerator +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ResourceLock("JCA_PROVIDERS") +internal class MldsaP384AdversarialTest { + @Test + fun `truncations forged lengths invalid points and noncanonical scalars cannot authenticate`() = withMldsaTestProvider { provider -> + val parameter = MldsaAlgorithm.MLDSA87_P384 + val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() + val data = "host signature parser adversarial test".toByteArray() + val key = SshSigning.encodePublicKey(pair).publicKeyBlob + val signature = SshSigning.signWithKeyPair(parameter.sshName, pair, data) + fun accepts(candidateKey: ByteArray = key, candidateSignature: ByteArray = signature): Boolean = try { + SignatureVerifier.verify(candidateKey, candidateSignature, data, parameter.sshName) + } catch (_: Exception) { + false + } + fun changedLength(bytes: ByteArray, offset: Int, length: Int) = bytes.copyOf().also { + ByteBuffer.wrap(it).putInt(offset, length) + } + assertTrue(accepts()) + for (size in key.indices) assertFalse(accepts(candidateKey = key.copyOf(size)), "key truncated to $size") + for (size in signature.indices) assertFalse(accepts(candidateSignature = signature.copyOf(size)), "signature truncated to $size") + for (length in listOf(0, Int.MAX_VALUE, Int.MIN_VALUE, -1)) { + assertFalse(accepts(candidateKey = changedLength(key, 0, length))) + assertFalse(accepts(candidateSignature = changedLength(signature, 0, length))) + } + val prefixSize = 4 + parameter.sshName.length + val qLengthOffset = prefixSize + 4 + 2592 + val rLengthOffset = prefixSize + 4 + 4627 + val parsed = SshSignature(ByteBufferKaitaiStream(signature)).apply { _read() } + val body = (parsed.signatureBlob() as SshMldsaP384SignatureBlob).signature() + val sLengthOffset = rLengthOffset + 4 + body.r().body().size + for (length in listOf(0, 1, 49, 50, 96, 98, 2692, 2694, 4636, 4734, Int.MAX_VALUE, Int.MIN_VALUE, -1)) { + assertFalse(accepts(candidateKey = changedLength(key, prefixSize, length)), "key length $length") + assertFalse(accepts(candidateKey = changedLength(key, qLengthOffset, length)), "point length $length") + assertFalse(accepts(candidateSignature = changedLength(signature, prefixSize, length)), "signature length $length") + for (offset in listOf(rLengthOffset, sLengthOffset)) { + if (length != ByteBuffer.wrap(signature).getInt(offset)) { + assertFalse(accepts(candidateSignature = changedLength(signature, offset, length)), "scalar length $length at $offset") + } + } + } + val qOffset = qLengthOffset + 4 + for (tag in listOf(0, 2, 3, 6, 7, 255)) { + assertFalse(accepts(candidateKey = key.copyOf().also { it[qOffset] = tag.toByte() })) + } + assertFalse(accepts(candidateKey = key.copyOf().also { it.fill(0, qOffset + 1, it.size) })) + assertFalse(accepts(candidateKey = key.copyOf().also { it.fill(0xff.toByte(), qOffset + 1, it.size) })) + val order = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973", 16) + val badScalars = listOf(byteArrayOf(), byteArrayOf(0), byteArrayOf(-1), byteArrayOf(0, 1), ByteArray(50) { 1 }, order.toByteArray(), (order + BigInteger.ONE).toByteArray()) + val prefix = encodeSshString(parameter.sshName.toByteArray(Charsets.US_ASCII)) + for (bad in badScalars) { + for ((r, s) in listOf(bad to body.s().body(), body.r().body() to bad)) { + val invalid = prefix + encodeSshString(body.mldsaSignature() + encodeSshString(r) + encodeSshString(s)) + assertFalse(accepts(candidateSignature = invalid)) + } + } + assertFalse(accepts(candidateKey = key + 0)) + assertFalse(accepts(candidateSignature = signature + 0)) + assertFalse(SignatureVerifier.verify(key, signature, data, "ssh-mldsa-87")) + assertTrue(accepts()) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384Test.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384Test.kt new file mode 100644 index 00000000..5d29fde6 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaP384Test.kt @@ -0,0 +1,95 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import io.kaitai.struct.ByteBufferKaitaiStream +import org.bouncycastle.jcajce.spec.ContextParameterSpec +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.connectbot.sshlib.SshSigning +import org.connectbot.sshlib.protocol.SshMldsaP384SignatureBlob +import org.connectbot.sshlib.protocol.SshSignature +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import java.math.BigInteger +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPoint +import java.security.spec.ECPublicKeySpec +import java.security.spec.X509EncodedKeySpec +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ResourceLock("JCA_PROVIDERS") +internal class MldsaP384Test { + @Test + fun `SSH composite verifies independently with BC ML-DSA and JCA ECDSA`() = withMldsaTestProvider { provider -> + val parameter = MldsaAlgorithm.MLDSA87_P384 + val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() + val message = "P-384 SSH composite test".toByteArray() + val key = SshSigning.encodePublicKey(pair) + val signature = SshSigning.signWithKeyPair(parameter.sshName, pair, message) + assertTrue(SignatureVerifier.verify(key.publicKeyBlob, signature, message, parameter.sshName)) + assertTrue(SignatureVerifier.verifyWithKeyType(key.publicKeyBlob, signature, message)) + assertFalse(SignatureVerifier.verify(key.publicKeyBlob, signature, message + 1, parameter.sshName)) + assertFalse(SignatureVerifier.verify(key.publicKeyBlob + 0, signature, message, parameter.sshName)) + assertFalse(SignatureVerifier.verify(key.publicKeyBlob, signature + 0, message, parameter.sshName)) + assertFalse(SignatureVerifier.verify(key.publicKeyBlob, signature, message, "ssh-mldsa-87")) + assertFailsWith { + SignatureVerifier.verify(key.publicKeyBlob, signature.copyOf(signature.size - 1), message, parameter.sshName) + } + val parsed = SshSignature(ByteBufferKaitaiStream(signature)).apply { _read() } + val blob = (parsed.signatureBlob() as SshMldsaP384SignatureBlob).signature() + val rawKey = MldsaAlgorithm.decodePublicKey(pair.public).second + val label = "COMPSIG-MLDSA87-ECDSA-P384-SHA512".toByteArray() + val representative = "CompositeAlgorithmSignatures2025".toByteArray() + label + byteArrayOf(0) + MessageDigest.getInstance("SHA-512").digest(message) + val bc = BouncyCastleProvider() + val mlKey = KeyFactory.getInstance("ML-DSA-87", bc).generatePublic(X509EncodedKeySpec(MldsaAlgorithm.MLDSA87.subjectPublicKeyInfo(rawKey.copyOfRange(0, 2592)))) + val mlVerifier = Signature.getInstance("ML-DSA-87", bc) + mlVerifier.initVerify(mlKey) + mlVerifier.setParameter(ContextParameterSpec(label)) + mlVerifier.update(representative) + assertTrue(mlVerifier.verify(blob.mldsaSignature())) + val params = AlgorithmParameters.getInstance("EC").apply { init(ECGenParameterSpec("secp384r1")) }.getParameterSpec(ECParameterSpec::class.java) + val q = rawKey.copyOfRange(2592, rawKey.size) + val point = ECPoint(BigInteger(1, q.copyOfRange(1, 49)), BigInteger(1, q.copyOfRange(49, 97))) + val ecKey = KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, params)) + val ecVerifier = Signature.getInstance("SHA384withECDSA") + ecVerifier.initVerify(ecKey) + ecVerifier.update(representative) + assertTrue(ecVerifier.verify(EcdsaSignatureAlgorithm.encodeDerEcdsaSignature(BigInteger(blob.r().body()), BigInteger(blob.s().body())))) + val prefix = encodeSshString(parameter.sshName.toByteArray()) + val encodedBody = blob.mldsaSignature() + encodeMpint(blob.r().body()) + encodeMpint(blob.s().body()) + for (offset in listOf(0, 4626, encodedBody.lastIndex)) { + val damaged = encodedBody.copyOf().also { it[offset] = (it[offset].toInt() xor 1).toByte() } + assertFalse(SignatureVerifier.verify(key.publicKeyBlob, prefix + encodeSshString(damaged), message, parameter.sshName)) + } + assertFalse(runCatching { SignatureVerifier.verify(key.publicKeyBlob, prefix + encodeSshString(encodedBody + 0), message, parameter.sshName) }.getOrDefault(false)) + for (badR in listOf(byteArrayOf(), byteArrayOf(0), byteArrayOf(-1), byteArrayOf(0, 1), ByteArray(50) { 1 })) { + val body = blob.mldsaSignature() + encodeSshString(badR) + encodeMpint(blob.s().body()) + assertFalse(runCatching { SignatureVerifier.verify(key.publicKeyBlob, prefix + encodeSshString(body), message, parameter.sshName) }.getOrDefault(false)) + } + assertTrue(parameter.isAvailable(listOf(provider))) + assertTrue(parameter.sshName in SignatureEntry.defaultString) + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithmTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithmTest.kt index 2ab42471..41be6242 100644 --- a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithmTest.kt +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/MldsaSignatureAlgorithmTest.kt @@ -70,7 +70,7 @@ internal class MldsaSignatureAlgorithmTest { private val reference = BouncyCastleProvider() @ParameterizedTest - @EnumSource(MldsaAlgorithm::class) + @EnumSource(value = MldsaAlgorithm::class, names = ["MLDSA87_P384"], mode = EnumSource.Mode.EXCLUDE) fun `signatures interoperate with an independent implementation`(parameter: MldsaAlgorithm) = withMldsaTestProvider { provider -> val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() val key = SshSigning.encodePublicKey(pair) @@ -83,7 +83,7 @@ internal class MldsaSignatureAlgorithmTest { assertFalse(SignatureVerifier.verify(key.publicKeyBlob, signature + 0, message, parameter.sshName)) val parsed = SshSignature(ByteBufferKaitaiStream(signature)).apply { _read() } val raw = (parsed.signatureBlob() as SshMldsaSignatureBlob).signature() - assertEquals(parameter.signatureSize, raw.size) + assertEquals(parameter.maxSignatureSize, raw.size) if (parameter == MldsaAlgorithm.MLDSA44_ED25519) { val publicBytes = MldsaAlgorithm.decodePublicKey(pair.public).second @@ -151,13 +151,13 @@ internal class MldsaSignatureAlgorithmTest { } @ParameterizedTest - @EnumSource(MldsaAlgorithm::class) + @EnumSource(value = MldsaAlgorithm::class, names = ["MLDSA87_P384"], mode = EnumSource.Mode.EXCLUDE) fun `schemas reject incorrect sizes and truncated payloads`(parameter: MldsaAlgorithm) = withMldsaTestProvider { provider -> val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() val key = SshSigning.encodePublicKey(pair) val signature = SshSigning.signWithKeyPair(parameter.sshName, pair, message) val prefix = encodeSshString(parameter.sshName.toByteArray()) - for (size in listOf(0, parameter.signatureSize - 1, parameter.signatureSize + 1)) { + for (size in listOf(0, parameter.maxSignatureSize - 1, parameter.maxSignatureSize + 1)) { assertFailsWith { SignatureVerifier.verify(key.publicKeyBlob, prefix + encodeSshString(ByteArray(size)), message, parameter.sshName) } @@ -181,7 +181,7 @@ internal class MldsaSignatureAlgorithmTest { assertEquals(SignatureEntry.SSH_MLDSA44_ED25519, SignatureEntry.defaults.first()) for (parameter in MldsaAlgorithm.entries) { assertFalse(parameter.isAvailable(emptyList())) - if (parameter != MldsaAlgorithm.MLDSA44_ED25519) assertFalse(parameter.sshName in SignatureEntry.defaultString) + if (!parameter.isComposite) assertFalse(parameter.sshName in SignatureEntry.defaultString) } } assertEquals(before, Security.getProviders().map { it.name }) @@ -198,14 +198,14 @@ internal class MldsaSignatureAlgorithmTest { } val providers = listOf(generic) assertFalse(MldsaAlgorithm.MLDSA44_ED25519.isAvailable(providers)) - for (parameter in MldsaAlgorithm.entries.filter { it != MldsaAlgorithm.MLDSA44_ED25519 }) { + for (parameter in MldsaAlgorithm.entries.filter { !it.isComposite }) { assertTrue(parameter.isAvailable(providers)) val pair = KeyPairGenerator.getInstance(parameter.jcaName, provider).generateKeyPair() val raw = MldsaAlgorithm.decodePublicKey(pair.public).second val signer = parameter.signer(pair.private, providers) signer.update(message) val signature = signer.sign() - assertEquals(parameter.signatureSize, signature.size) + assertEquals(parameter.maxSignatureSize, signature.size) val verifier = parameter.verifier(raw, providers) verifier.update(message) assertTrue(verifier.verify(signature)) diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchangeTest.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchangeTest.kt new file mode 100644 index 00000000..f1add0fd --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/SntrupHybridKeyExchangeTest.kt @@ -0,0 +1,142 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto + +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKEMGenerator +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeParameters +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimePublicKeyParameters +import org.connectbot.sshlib.SshException +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.security.MessageDigest +import java.security.SecureRandom +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SntrupHybridKeyExchangeTest { + private val privateKey = ByteArray(32) { 7 } + private val serverPublic = ByteArray(32) { 9 } + + private fun classical(secret: ByteArray = ByteArray(32) { 5 }) = object : X25519Provider { + override fun generatePrivateKey(): ByteArray = privateKey.clone() + override fun publicFromPrivate(privateKey: ByteArray): ByteArray = ByteArray(32) { 11 } + override fun computeSharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray { + assertContentEquals(serverPublic, publicKey) + return secret.clone() + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 128]) + fun `BC server produces the same RFC 9941 shared secret preserving X25519 leading bytes`(first: Int) { + val ecSecret = ByteArray(32) { it.toByte() }.also { it[0] = first.toByte() } + val kex = SntrupHybridKeyExchange(classical(ecSecret)) + try { + val client = kex.generateClientKeys() + assertEquals(1190, client.size) + assertContentEquals(ByteArray(32) { 11 }, client.copyOfRange(1158, 1190)) + val serverKey = SNTRUPrimePublicKeyParameters(SNTRUPrimeParameters.sntrup761, client.copyOfRange(0, 1158)) + val encapsulated = SNTRUPrimeKEMGenerator(SecureRandom()).generateEncapsulated(serverKey) + try { + val expected = byteArrayOf(0, 0, 0, 64) + MessageDigest.getInstance("SHA-512").digest(encapsulated.secret + ecSecret) + val actual = kex.computeSharedSecret(encapsulated.encapsulation + serverPublic) + assertContentEquals(expected, actual) + assertEquals(68, actual.size) + assertEquals("SHA-512", kex.hashAlgorithm) + } finally { + encapsulated.destroy() + } + } finally { + kex.zeroize() + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 1070, 1072]) + fun `wrong length reply is rejected before X25519`(size: Int) { + var called = false + val provider = object : X25519Provider by classical() { + override fun computeSharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray { + called = true + return ByteArray(32) { 1 } + } + } + val kex = SntrupHybridKeyExchange(provider) + try { + kex.generateClientKeys() + assertFailsWith { kex.computeSharedSecret(ByteArray(size)) } + assertTrue(!called) + } finally { + kex.zeroize() + } + } + + @ParameterizedTest + @ValueSource(ints = [0, 31, 32, 33]) + fun `invalid or all-zero X25519 secret is rejected`(size: Int) { + val kex = SntrupHybridKeyExchange(classical(ByteArray(size))) + try { + kex.generateClientKeys() + assertFailsWith { kex.computeSharedSecret(ByteArray(1039) + serverPublic) } + } finally { + kex.zeroize() + } + } + + @Test + fun `zeroize erases retained X25519 private bytes and makes KEX unusable`() { + val retained = privateKey.clone() + val provider = object : X25519Provider by classical() { + override fun generatePrivateKey(): ByteArray = retained + } + val kex = SntrupHybridKeyExchange(provider) + assertFailsWith { kex.computeSharedSecret(ByteArray(1071)) } + kex.generateClientKeys() + kex.zeroize() + kex.zeroize() + assertTrue(retained.all { it == 0.toByte() }) + assertFailsWith { kex.computeSharedSecret(ByteArray(1071)) } + } + + @ParameterizedTest + @ValueSource(strings = ["sntrup761x25519-sha512", "sntrup761x25519-sha512@openssh.com"]) + fun `both registered names use the hybrid implementation and precede classical defaults`(name: String) { + val entry = KexEntry.fromSshName(name)!! + assertIs(entry.create()) + assertEquals(KexType.ECDH, entry.type) + assertTrue(entry.isPostQuantum) + assertTrue(KexEntry.defaults.indexOf(entry) < KexEntry.defaults.indexOf(KexEntry.CURVE25519_SHA256)) + } + + @Test + fun `X25519 key generation failure erases private bytes and clears partial KEX state`() { + val retained = privateKey.clone() + val provider = object : X25519Provider by classical() { + override fun generatePrivateKey(): ByteArray = retained + override fun publicFromPrivate(privateKey: ByteArray): ByteArray = throw java.security.InvalidKeyException("test failure") + } + val kex = SntrupHybridKeyExchange(provider) + assertFailsWith { kex.generateClientKeys() } + assertTrue(retained.all { it == 0.toByte() }) + assertFailsWith { kex.computeSharedSecret(ByteArray(1071)) } + } +} diff --git a/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761Test.kt b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761Test.kt new file mode 100644 index 00000000..aeedb760 --- /dev/null +++ b/sshlib/src/test/kotlin/org/connectbot/sshlib/crypto/sntrup/Sntrup761Test.kt @@ -0,0 +1,115 @@ +/* + * ConnectBot SSH Library + * Copyright 2026 Kenny Root + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.connectbot.sshlib.crypto.sntrup + +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKEMGenerator +import org.connectbot.sshlib.SshException +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.security.SecureRandom +import kotlin.random.Random +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKEMExtractor as BcExtractor +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKeyGenerationParameters as BcKeyGenerationParameters +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeKeyPairGenerator as BcKeyPairGenerator +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimeParameters as BcParameters +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimePrivateKeyParameters as BcPrivateKey +import org.bouncycastle.pqc.crypto.ntruprime.SNTRUPrimePublicKeyParameters as BcPublicKey + +class Sntrup761Test { + private fun random(seed: Int) = SecureRandom.getInstance("SHA1PRNG").apply { + setSeed(byteArrayOf(seed.toByte())) + } + + @ParameterizedTest + @ValueSource(ints = [0, 1, 2, 127, 255]) + fun `extracted key generation and decapsulation match BC including invalid ciphertext fallback`(seed: Int) { + val generator = SNTRUPrimeKeyPairGenerator() + generator.init(SNTRUPrimeKeyGenerationParameters(random(seed), SNTRUPrimeParameters.sntrup761)) + val actual = generator.generateKeyPair() + val actualPublic = actual.getPublic() as SNTRUPrimePublicKeyParameters + val actualPrivate = actual.getPrivate() as SNTRUPrimePrivateKeyParameters + + val bcGenerator = BcKeyPairGenerator() + bcGenerator.init(BcKeyGenerationParameters(random(seed), BcParameters.sntrup761)) + val expected = bcGenerator.generateKeyPair() + val expectedPublic = expected.getPublic() as BcPublicKey + val expectedPrivate = expected.getPrivate() as BcPrivateKey + assertEquals(1158, actualPublic.encoded.size) + assertEquals(1763, actualPrivate.encoded.size) + assertContentEquals(expectedPublic.encoded, actualPublic.encoded) + assertContentEquals(expectedPrivate.encoded, actualPrivate.encoded) + + val encapsulated = SNTRUPrimeKEMGenerator(random(seed + 10)).generateEncapsulated(expectedPublic) + try { + assertEquals(1039, encapsulated.encapsulation.size) + val extracted = SNTRUPrimeKEMExtractor(actualPrivate) + val bc = BcExtractor(expectedPrivate) + assertContentEquals(encapsulated.secret, extracted.extractSecret(encapsulated.encapsulation)) + // Exercise both the polynomial body and the confirmation-hash portion. + for (offset in listOf(0, 1006, 1007, 1038)) { + val damaged = encapsulated.encapsulation.also { it[offset] = (it[offset].toInt() xor 1).toByte() } + val rejected = extracted.extractSecret(damaged) + assertContentEquals(bc.extractSecret(damaged), rejected) + assertFalse(encapsulated.secret.contentEquals(rejected)) + } + } finally { + encapsulated.destroy() + actualPrivate.clear() + } + assertTrue(actualPrivate.encoded.all { it == 0.toByte() }) + } + + @Test + fun `arbitrary fixed length ciphertexts match independent BC implicit rejection`() { + val generator = SNTRUPrimeKeyPairGenerator() + generator.init(SNTRUPrimeKeyGenerationParameters(random(8), SNTRUPrimeParameters.sntrup761)) + val pair = generator.generateKeyPair() + val key = pair.getPrivate() as SNTRUPrimePrivateKeyParameters + val bcGenerator = BcKeyPairGenerator().apply { init(BcKeyGenerationParameters(random(8), BcParameters.sntrup761)) } + val bcKey = bcGenerator.generateKeyPair().getPrivate() as BcPrivateKey + try { + val extractor = SNTRUPrimeKEMExtractor(key) + val bc = BcExtractor(bcKey) + val random = Random(0x761) + val inputs = listOf(ByteArray(1039), ByteArray(1039) { 0xff.toByte() }, ByteArray(1039) { it.toByte() }) + + List(32) { random.nextBytes(1039) } + for (ciphertext in inputs) { + assertContentEquals(bc.extractSecret(ciphertext), extractor.extractSecret(ciphertext)) + } + } finally { + key.clear() + } + } + + @Test + fun `adapter rejects wrong length ciphertext and cannot use cleared keys`() { + val keys = Sntrup761KeyPair.generate(random(3)) + for (size in listOf(0, 1038, 1040)) { + assertFailsWith { keys.decapsulate(ByteArray(size)) } + } + keys.clear() + keys.clear() + assertFailsWith { keys.decapsulate(ByteArray(1039)) } + } +} diff --git a/third_party/sntrup/README.md b/third_party/sntrup/README.md new file mode 100644 index 00000000..82eaaa8b --- /dev/null +++ b/third_party/sntrup/README.md @@ -0,0 +1,57 @@ +# Vendored SNTRUP761 + +The SSH client uses a client-only extraction of Bouncy Castle's pure Java Streamlined +NTRU Prime implementation. BC remains a **test-only** dependency for interoperability +checks; the shipped library does not depend on it. + +`upstream.json` identifies the published Maven sources artifact and records SHA-256 +hashes of both the original upstream files and their adapted outputs. The hashes are +different because the extraction intentionally: + +- Relocates all types into `org.connectbot.sshlib.crypto.sntrup` and makes them + package-private, behind the internal Kotlin adapter. +- Removes encapsulation and NTRU LPRime-only helpers, including their AES dependencies. +- Uses JDK SHA-512, array utilities, constant-time digest comparison, and SecureRandom + instead of BC's implementations and global registrar. +- Adds private-key clearing for the connection's KEX lifecycle. +- Makes byte narrowing explicit in the small-polynomial encoder, preserving Java's + compound-assignment semantics. + +The polynomial arithmetic is retained from upstream. The original license is shipped +as `META-INF/LICENSE-sntrup.md` in the library JAR. + +## Release monitoring + +Renovate tracks the manifest's `version` using the Maven datasource, grouped with the +BC test dependency. On every PR, push, and daily CI run, the provenance job downloads +that published version's sources JAR and checks: + +1. Every original file and the license against its recorded upstream hash. +2. The deterministic extraction against its recorded extracted hash. +3. The generated bytes against the checked-in Java sources and license. + +A release that changes unrelated BC files passes. A change anywhere in one of our +source files fails, including changes to helper methods excluded from the extraction. +The check never rewrites files or accepts new hashes. BC updates do not automerge. + +## Reviewing an update + +Run `python3 .github/scripts/vendor_sntrup.py --check` to see which released files +changed. Compare those files in the published `bcprov-jdk18on--sources.jar` +against the artifact identified by `reviewedVersion`. Review whether fixes to utilities +we replaced need corresponding changes in our JDK adapters. Then run: + +```sh +python3 .github/scripts/vendor_sntrup.py --update +python3 .github/scripts/vendor_sntrup.py --check +./gradlew :sshlib:test --tests '*Sntrup*' --tests '*SshClientIntegrationTest.should connect with kex algorithm*' +``` + +`--update` explicitly accepts the current upstream bytes, regenerates the extraction, +and refreshes both sets of hashes and `reviewedVersion`. Inspect the generated changes +and run the interoperability tests before merging. If upstream changes structure, adapt +the extraction script and review its generated output; do not just replace hashes. + +For offline checks, either command accepts `--archive /path/to/bcprov-sources.jar`. +The archive must correspond to the manifest version. Do not edit generated Java files +directly: make local adaptations in the script so regeneration remains reproducible. diff --git a/third_party/sntrup/upstream.json b/third_party/sntrup/upstream.json new file mode 100644 index 00000000..b49a771f --- /dev/null +++ b/third_party/sntrup/upstream.json @@ -0,0 +1,71 @@ +{ + "artifact": "org.bouncycastle:bcprov-jdk18on", + "version": "1.86", + "reviewedVersion": "1.86", + "files": { + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimeKEMExtractor.java": { + "originalSha256": "85a252478fd33b48e804738f6d45c53d5e60b59b0eb2451ef520b2f61c17ca8f", + "extractedSha256": "3842c08b57612a69823e7940af66531b8ca4e834baa12fcabb387d080a804335" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimeKeyGenerationParameters.java": { + "originalSha256": "4cd69716f2e806aeb3617eede613503a46882b94576ecc24943384a34fb31488", + "extractedSha256": "68cb7a79cb1659f2091a2e123ff0a3f0f6c7fbb1ac9929c24d8af67bc7ba8a7d" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimeKeyPairGenerator.java": { + "originalSha256": "71439ef7d4a0d88fa5710794f124452d80da4aec01868c46d13810d7bc710887", + "extractedSha256": "51b5472470b9af3f0314cd6595e2315871b054fbec18fbdaa2fe443795bbf3be" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimeKeyParameters.java": { + "originalSha256": "2534ad5a79597b1a6d6fbf84689969ba6ef19e3e63e1bcbd79042add8ca0b46e", + "extractedSha256": "17544a51504fd80a1d350e6818f4cdc3891cfc8d7012d5f724cdea78f142cafe" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimeParameters.java": { + "originalSha256": "2955a85e3dfb22b659ce5033b76f09322ec67dbdb13f2e2cade58a094af8e0ed", + "extractedSha256": "d3b676c5c055a83e7a23aebf7dd6bfb101ee78e7deeb52278b80328edb064034" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimePrivateKeyParameters.java": { + "originalSha256": "150262e2dcf1fdb897492122b6379aa6cf8bd9da49c029316cdd823d41dd1da0", + "extractedSha256": "8fb3a8926cbd91185426d24db6b4c26685b709c26b6b182dc1349d3abb589f71" + }, + "org/bouncycastle/pqc/crypto/ntruprime/SNTRUPrimePublicKeyParameters.java": { + "originalSha256": "0b334851bcdaaacb9454eb4dc9024fc05034a16233ce90d5c34f0a52f7baaea0", + "extractedSha256": "87f31280bc01cb3fa78ddab2e787425e16732a6a99b644f36fbcc39c92aa81ae" + }, + "org/bouncycastle/pqc/crypto/ntruprime/Utils.java": { + "originalSha256": "21fca15a0d94e6c1aca6c7091f18e28cfdaaf92c2dfcc9a9ba74c695724d1157", + "extractedSha256": "e4ec5444c509e55d6633a51578fb3e4810756f1da423589062cb5d13391cc61c" + }, + "org/bouncycastle/crypto/AsymmetricCipherKeyPair.java": { + "originalSha256": "7c898609c9a6377f9b8be32459b428c752bb980595a0c322931b4edf25c17212", + "extractedSha256": "bd3ba09e3292d930f225a545600340c4399a67e01f53362df47925193ba4e8df" + }, + "org/bouncycastle/crypto/AsymmetricCipherKeyPairGenerator.java": { + "originalSha256": "97debb19427049ed4cc25b02f95458ef7a5b25a11a00b0b3e6fe2419ef50bfe2", + "extractedSha256": "2f344a19a72b8f824bcbac35d307a96963e89145d8c860e53b8fa6ab615732b4" + }, + "org/bouncycastle/crypto/KeyGenerationParameters.java": { + "originalSha256": "9194caee9ce205b9cd46166222fe2d706f26edd7b62c0642637b34a25e88b8c8", + "extractedSha256": "3494ee8723d23f5bddbc6b6efe6cde1d512fb0954ec81f7dbe96993a1d6b03cd" + }, + "org/bouncycastle/crypto/params/AsymmetricKeyParameter.java": { + "originalSha256": "75adec82d927cbef515bada736330880594966aa12723b5b4640a7338f6f98dd", + "extractedSha256": "5e30847d03884331ee2526c6cafdfc5550da575c69d0f38d205cf9d60275808d" + }, + "org/bouncycastle/pqc/crypto/KEMParameters.java": { + "originalSha256": "81b888010266847c7ba8fb70a1c2eb0b7c921e3c8f71822b88b18b8efade018d", + "extractedSha256": "8bc0e3873d93cc416b6cde60b2e03b18947a1e2d6636c8c711b7b254d649a693" + }, + "org/bouncycastle/crypto/EncapsulatedSecretExtractor.java": { + "originalSha256": "ba69318204e88acb844ca475f21a07fa3dd85db1dce64ed612a29128efa590f1", + "extractedSha256": "86a8aba9b6146934358fc8b4b69021d083363167408f2d9094a1066e65c3dde5" + }, + "org/bouncycastle/crypto/CipherParameters.java": { + "originalSha256": "30d32bdb9c782a37b1c8437bc522b344767418b06f0ee5d0f710023c89041a7e", + "extractedSha256": "819585a616b3360ef384dc216bef22eb4978c20aed38dddd8f386e5d5e40bb87" + }, + "META-INF/LICENSE.md": { + "originalSha256": "0e01f1549c9022f406392ac2947d32223b7c2e977d21ea2f8c182fdeb4dae5fd", + "extractedSha256": "0e01f1549c9022f406392ac2947d32223b7c2e977d21ea2f8c182fdeb4dae5fd" + } + } +}