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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/scripts/test_vendor_sntrup.py
Original file line number Diff line number Diff line change
@@ -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()
212 changes: 212 additions & 0 deletions .github/scripts/vendor_sntrup.py
Original file line number Diff line number Diff line change
@@ -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}")
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading