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
11 changes: 0 additions & 11 deletions SimpleAPI/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<bouncycastle.version>1.85</bouncycastle.version>
</properties>
<build>
<finalName>${project.name}</finalName>
Expand Down Expand Up @@ -255,16 +254,6 @@
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ private static boolean matchesProfile(ClientCredential credential, HttpClientPro
credential.certificate().verify(credential.caCertificate().getPublicKey());
java.util.List<String> usage = credential.certificate().getExtendedKeyUsage();
boolean[] keyUsage = credential.certificate().getKeyUsage();
if (usage == null || !usage.contains(org.bouncycastle.asn1.x509.KeyPurposeId.id_kp_clientAuth.getId())
if (usage == null || !usage.contains("1.3.6.1.5.5.7.3.2")
|| keyUsage == null || !keyUsage[0]) return false;
String expected = "urn:votingplugin:http-backend:" + profile.serverId();
var names = credential.certificate().getSubjectAlternativeNames();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.bencodez.simpleapi.servercomm.http;

import java.io.IOException;
import java.math.BigInteger;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
Expand All @@ -15,14 +14,12 @@
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Principal;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.Clock;
import java.time.Duration;
import java.net.InetAddress;
import java.util.Date;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
Expand All @@ -33,22 +30,6 @@
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509ExtendedKeyManager;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.IPAddress;
import com.bencodez.simpleapi.file.DurableFiles;
import com.bencodez.simpleapi.file.PrivateFilePermissions;

Expand Down Expand Up @@ -146,7 +127,6 @@ static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost, Clock
throw new IOException("HTTP TLS identity files are invalid");
boolean caRenewed = needsCaRenewal(caCertificate, clock);
if (caRenewed) {
ensureBouncyCastle();
KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey);
caCertificate = certificate("CN=VotingPlugin HTTP private CA", caPair, null, null,
CertificateRole.CA, null, clock.instant());
Expand All @@ -156,7 +136,6 @@ static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost, Clock
writeStore(caFile, ca, password);
}
if (caRenewed || !hasServerName(serverCertificate, advertisedHost) || needsRenewal(serverCertificate, clock)) {
ensureBouncyCastle();
KeyPair serverPair = keyPair();
serverCertificate = certificate("CN=" + certificateName(advertisedHost), serverPair, caCertificate, caKey,
CertificateRole.SERVER, advertisedHost, clock.instant());
Expand All @@ -177,7 +156,6 @@ static HttpTlsIdentity loadOrCreate(Path directory, String advertisedHost, Clock
if (persistentTransportState)
throw new IOException("HTTP TLS identity files are missing");
if (!initializing) writeInitializationMarker(initializingFile);
ensureBouncyCastle();
char[] password = HttpTransportSecrets.randomToken().toCharArray();
try {
KeyPair caPair = keyPair();
Expand Down Expand Up @@ -265,7 +243,6 @@ private synchronized void renewIdentityIfNeeded() throws Exception {
serverCertificate = persistedServer;
renewCa = needsCaRenewal(caCertificate, clock);
if (!renewCa && !needsRenewal(serverCertificate, clock)) return;
ensureBouncyCastle();
X509Certificate replacementCa = caCertificate;
if (renewCa) {
KeyPair caPair = new KeyPair(caCertificate.getPublicKey(), caKey);
Expand Down Expand Up @@ -297,7 +274,6 @@ public IssuedClientCertificate issueClientCertificate(String serverId) throws Ex
IssuedClientCertificate issueClientCertificate(String serverId, Instant issuedAt) throws Exception {
serverId = canonicalServerId(serverId);
if (issuedAt == null) throw new IllegalArgumentException("Certificate issuance time is required");
ensureBouncyCastle();
KeyPair pair = keyPair();
X509Certificate certificate = certificate("CN=" + serverId, pair, caCertificate, caKey, CertificateRole.CLIENT,
"urn:votingplugin:http-backend:" + serverId, issuedAt);
Expand Down Expand Up @@ -334,12 +310,12 @@ public boolean validClientCertificate(String expectedServerId, X509Certificate c
try {
List<String> usage = certificate.getExtendedKeyUsage();
boolean[] keyUsage = certificate.getKeyUsage();
if (usage == null || !usage.contains(KeyPurposeId.id_kp_clientAuth.getId()) || keyUsage == null || !keyUsage[0]) return false;
if (usage == null || !usage.contains("1.3.6.1.5.5.7.3.2") || keyUsage == null || !keyUsage[0]) return false;
String expectedUri = "urn:votingplugin:http-backend:" + canonicalServerId(expectedServerId);
Collection<List<?>> names = certificate.getSubjectAlternativeNames();
if (names == null) return false;
for (List<?> name : names) {
if (name.size() == 2 && Integer.valueOf(GeneralName.uniformResourceIdentifier).equals(name.get(0))
if (name.size() == 2 && Integer.valueOf(6).equals(name.get(0))
&& expectedUri.equals(name.get(1))) return true;
}
return false;
Expand All @@ -363,30 +339,8 @@ private static KeyPair keyPair() throws Exception {

private static X509Certificate certificate(String subject, KeyPair subjectKey, X509Certificate issuer, PrivateKey issuerKey,
CertificateRole role, String subjectAlternativeName, Instant now) throws Exception {
X500Name issuerName = issuer == null ? new X500Name(subject) : new X500Name(issuer.getSubjectX500Principal().getName());
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(issuerName,
new BigInteger(160, new java.security.SecureRandom()).setBit(159), Date.from(now.minusSeconds(300)),
Date.from(now.plusSeconds(role == CertificateRole.CA ? 315360000L : 31536000L)), new X500Name(subject), subjectKey.getPublic());
builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(role == CertificateRole.CA));
builder.addExtension(Extension.keyUsage, true, new KeyUsage(role == CertificateRole.CA ? KeyUsage.keyCertSign | KeyUsage.cRLSign
: KeyUsage.digitalSignature));
if (role == CertificateRole.SERVER) builder.addExtension(Extension.extendedKeyUsage, false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth));
if (role == CertificateRole.CLIENT) builder.addExtension(Extension.extendedKeyUsage, false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth));
if (role == CertificateRole.SERVER && subjectAlternativeName != null) {
GeneralName name;
if (subjectAlternativeName.matches("(?:\\d{1,3}\\.){3}\\d{1,3}") || subjectAlternativeName.indexOf(':') >= 0)
name = new GeneralName(GeneralName.iPAddress, subjectAlternativeName);
else name = new GeneralName(GeneralName.dNSName, subjectAlternativeName);
builder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(name));
}
if (role == CertificateRole.CLIENT) builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.uniformResourceIdentifier, subjectAlternativeName)));
ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA").setProvider("BC")
.build(issuerKey == null ? subjectKey.getPrivate() : issuerKey);
X509CertificateHolder holder = builder.build(signer);
return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder);
return JdkX509CertificateGenerator.create(subject, subjectKey, issuer, issuerKey, role == CertificateRole.CA,
role == CertificateRole.SERVER, subjectAlternativeName, now);
}

static boolean needsRenewal(X509Certificate certificate, Clock clock) {
Expand All @@ -397,9 +351,6 @@ static boolean needsCaRenewal(X509Certificate certificate, Clock clock) {
return certificate == null || !certificate.getNotAfter().toInstant().isAfter(clock.instant().plus(CA_RENEW_BEFORE));
}

private static void ensureBouncyCastle() {
if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider());
}

private static String certificateName(String host) {
return host.replaceAll("[^A-Za-z0-9 ._-]", "_");
Expand All @@ -411,8 +362,8 @@ private static boolean hasServerName(X509Certificate certificate, String adverti
if (names == null) return false;
for (List<?> name : names) {
if (name.size() != 2 || !(name.get(1) instanceof String value)) continue;
if (Integer.valueOf(GeneralName.dNSName).equals(name.get(0)) && advertisedHost.equalsIgnoreCase(value)) return true;
if (Integer.valueOf(GeneralName.iPAddress).equals(name.get(0)) && sameIpAddress(advertisedHost, value)) return true;
if (Integer.valueOf(2).equals(name.get(0)) && advertisedHost.equalsIgnoreCase(value)) return true;
if (Integer.valueOf(7).equals(name.get(0)) && sameIpAddress(advertisedHost, value)) return true;
}
return false;
} catch (Exception failure) { return false; }
Expand All @@ -426,7 +377,7 @@ private static boolean sameIpAddress(String first, String second) {
}

private static boolean isIpLiteral(String value) {
return IPAddress.isValid(value);
return JdkX509CertificateGenerator.isIpLiteral(value);
}

private static Path safe(Path file) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package com.bencodez.simpleapi.servercomm.http;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.security.auth.x500.X500Principal;

/** Creates the private HTTP transport PKI using only standard JCA primitives. */
final class JdkX509CertificateGenerator {
private static final byte[] ECDSA_SHA256 = sequence(oid("1.2.840.10045.4.3.2"));
private static final DateTimeFormatter UTC_TIME = DateTimeFormatter.ofPattern("yyMMddHHmmss'Z'")
.withZone(ZoneOffset.UTC);
private static final DateTimeFormatter GENERALIZED_TIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss'Z'")
.withZone(ZoneOffset.UTC);

private JdkX509CertificateGenerator() { }

static X509Certificate create(String subject, KeyPair subjectKey, X509Certificate issuer, PrivateKey issuerKey,
boolean certificateAuthority, boolean server, String subjectAlternativeName, Instant now) throws Exception {
byte[] issuerName = issuer == null ? new X500Principal(subject).getEncoded()
: issuer.getSubjectX500Principal().getEncoded();
List<byte[]> extensions = new ArrayList<>();
extensions.add(extension("2.5.29.19", true,
certificateAuthority ? sequence(bool(true)) : sequence()));
extensions.add(extension("2.5.29.15", true,
certificateAuthority ? bitString(1, new byte[] { 0x06 }) : bitString(7, new byte[] { (byte) 0x80 })));
if (!certificateAuthority) extensions.add(extension("2.5.29.37", false,
sequence(oid(server ? "1.3.6.1.5.5.7.3.1" : "1.3.6.1.5.5.7.3.2"))));
if (subjectAlternativeName != null) {
byte tag;
byte[] value;
byte[] ipAddress = server ? parseIpLiteral(subjectAlternativeName) : null;
if (ipAddress != null) {
tag = (byte) 0x87;
value = ipAddress;
} else {
if (server && (subjectAlternativeName.indexOf(':') >= 0
|| subjectAlternativeName.matches("(?:\\d{1,3}\\.){3}\\d{1,3}")))
throw new IllegalArgumentException("Advertised HTTPS host is an invalid IP address");
if (!StandardCharsets.US_ASCII.newEncoder().canEncode(subjectAlternativeName))
throw new IllegalArgumentException("Certificate alternative name must be ASCII");
tag = server ? (byte) 0x82 : (byte) 0x86;
value = subjectAlternativeName.getBytes(StandardCharsets.US_ASCII);
}
extensions.add(extension("2.5.29.17", false, sequence(tagged(tag, value))));
}
byte[] tbs = sequence(
tagged((byte) 0xa0, integer(BigInteger.valueOf(2))),
integer(new BigInteger(160, new java.security.SecureRandom()).setBit(159)), ECDSA_SHA256, issuerName,
sequence(time(now.minusSeconds(300)),
time(now.plusSeconds(certificateAuthority ? 315360000L : 31536000L))),
new X500Principal(subject).getEncoded(), subjectKey.getPublic().getEncoded(),
tagged((byte) 0xa3, sequence(extensions.toArray(byte[][]::new))));
Signature signer = Signature.getInstance("SHA256withECDSA");
signer.initSign(issuerKey == null ? subjectKey.getPrivate() : issuerKey);
signer.update(tbs);
byte[] encoded = sequence(tbs, ECDSA_SHA256, bitString(0, signer.sign()));
try (ByteArrayInputStream input = new ByteArrayInputStream(encoded)) {
return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(input);
}
}

static boolean isIpLiteral(String value) {
return parseIpLiteral(value) != null;
}

private static byte[] parseIpLiteral(String value) {
if (value == null || value.isEmpty()) return null;
if (value.indexOf(':') >= 0) {
if (!value.matches("[0-9A-Fa-f:.]+")) return null;
try {
byte[] parsed = InetAddress.getByName(value).getAddress();
if (parsed.length == 16) return parsed;
if (parsed.length == 4) {
byte[] mapped = new byte[16];
mapped[10] = (byte) 0xff;
mapped[11] = (byte) 0xff;
System.arraycopy(parsed, 0, mapped, 12, parsed.length);
return mapped;
}
return null;
} catch (Exception invalid) { return null; }
}
String[] parts = value.split("\\.", -1);
if (parts.length != 4) return null;
for (String part : parts) {
if (part.isEmpty() || part.length() > 3 || !part.chars().allMatch(Character::isDigit)) return null;
try { if (Integer.parseInt(part) > 255) return null; }
catch (NumberFormatException invalid) { return null; }
}
try { return InetAddress.getByName(value).getAddress(); }
catch (Exception invalid) { return null; }
}

private static byte[] extension(String id, boolean critical, byte[] value) {
return critical ? sequence(oid(id), bool(true), octetString(value)) : sequence(oid(id), octetString(value));
}
private static byte[] time(Instant value) {
int year = value.atZone(ZoneOffset.UTC).getYear();
String encoded = year >= 1950 && year <= 2049 ? UTC_TIME.format(value) : GENERALIZED_TIME.format(value);
return tagged(year >= 1950 && year <= 2049 ? (byte) 0x17 : (byte) 0x18,
encoded.getBytes(StandardCharsets.US_ASCII));
}
private static byte[] oid(String value) {
String[] components = value.split("\\.");
ByteArrayOutputStream body = new ByteArrayOutputStream();
writeBase128(body, Long.parseLong(components[0]) * 40L + Long.parseLong(components[1]));
for (int index = 2; index < components.length; index++) writeBase128(body, Long.parseLong(components[index]));
return tagged((byte) 0x06, body.toByteArray());
}
private static void writeBase128(ByteArrayOutputStream output, long value) {
byte[] encoded = new byte[10];
int position = encoded.length;
encoded[--position] = (byte) (value & 0x7f);
while ((value >>>= 7) != 0) encoded[--position] = (byte) ((value & 0x7f) | 0x80);
output.writeBytes(Arrays.copyOfRange(encoded, position, encoded.length));
}
private static byte[] integer(BigInteger value) { return tagged((byte) 0x02, value.toByteArray()); }
private static byte[] bool(boolean value) { return tagged((byte) 0x01, new byte[] { value ? (byte) 0xff : 0 }); }
private static byte[] octetString(byte[] value) { return tagged((byte) 0x04, value); }
private static byte[] bitString(int unusedBits, byte[] value) {
byte[] body = new byte[value.length + 1];
body[0] = (byte) unusedBits;
System.arraycopy(value, 0, body, 1, value.length);
return tagged((byte) 0x03, body);
}
private static byte[] sequence(byte[]... values) { return tagged((byte) 0x30, concatenate(values)); }
private static byte[] tagged(byte tag, byte[] value) {
ByteArrayOutputStream output = new ByteArrayOutputStream(value.length + 6);
output.write(tag);
writeLength(output, value.length);
output.writeBytes(value);
return output.toByteArray();
}
private static void writeLength(ByteArrayOutputStream output, int length) {
if (length < 128) { output.write(length); return; }
int bytes = 0;
for (int remaining = length; remaining != 0; remaining >>>= 8) bytes++;
output.write(0x80 | bytes);
for (int shift = (bytes - 1) * 8; shift >= 0; shift -= 8) output.write(length >>> shift);
}
private static byte[] concatenate(byte[]... values) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (byte[] value : values) output.writeBytes(value);
return output.toByteArray();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ void connectionCodeRejectsExplicitZeroPort() {

@TempDir Path directory;

@Test
void tlsIdentityRejectsInvalidIpAndNonAsciiAlternativeNames() {
assertThrows(IllegalArgumentException.class,
() -> HttpTlsIdentity.loadOrCreate(directory.resolve("invalid-ip"), "999.1.1.1"));
assertThrows(IllegalArgumentException.class,
() -> HttpTlsIdentity.loadOrCreate(directory.resolve("invalid-ipv6"), "not:an:ip"));
assertThrows(IllegalArgumentException.class,
() -> HttpTlsIdentity.loadOrCreate(directory.resolve("non-ascii"), "tést.example"));
}

@Test
void tlsIdentityAcceptsIpv4MappedIpv6AlternativeNames() {
assertTrue(JdkX509CertificateGenerator.isIpLiteral("::ffff:192.0.2.1"));
assertTrue(JdkX509CertificateGenerator.isIpLiteral("::ffff:c000:201"));
assertTrue(JdkX509CertificateGenerator.isIpLiteral("0:0:0:0:0:ffff:c000:201"));
assertDoesNotThrow(() -> HttpTlsIdentity.loadOrCreate(directory.resolve("mapped-ipv6-dotted"),
"::ffff:192.0.2.1"));
assertDoesNotThrow(() -> HttpTlsIdentity.loadOrCreate(directory.resolve("mapped-ipv6-hex"),
"::ffff:c000:201"));
}

@Test
void backendResponseReaderRejectsBodiesBeyondTheWireLimit() throws Exception {
byte[] maximum = new byte[HttpTransportProtocol.MAX_BODY_BYTES];
Expand Down
Loading
Loading