Skip to content

Commit

Permalink
core: SpiffeUtil API for extracting Spiffe URI and loading TrustBundl…
Browse files Browse the repository at this point in the history
…es (grpc#11575)

Additional API for SpiffeUtil:
 - extract Spiffe URI from certificate chain
 - load Spiffe Trust Bundle from filesystem [json spec][] [JWK spec][]

JsonParser was changed to reject duplicate keys in objects.

[json spec]: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md
[JWK spec]: https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#61-publishing-spiffe-bundle-elements
  • Loading branch information
erm-g authored Oct 17, 2024
1 parent 1e0928f commit 4be69e3
Show file tree
Hide file tree
Showing 18 changed files with 725 additions and 7 deletions.
7 changes: 5 additions & 2 deletions core/src/main/java/io/grpc/internal/JsonParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.grpc.internal;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;

import com.google.gson.stream.JsonReader;
Expand All @@ -41,7 +42,8 @@ private JsonParser() {}

/**
* Parses a json string, returning either a {@code Map<String, ?>}, {@code List<?>},
* {@code String}, {@code Double}, {@code Boolean}, or {@code null}.
* {@code String}, {@code Double}, {@code Boolean}, or {@code null}. Fails if duplicate names
* found.
*/
public static Object parse(String raw) throws IOException {
JsonReader jr = new JsonReader(new StringReader(raw));
Expand Down Expand Up @@ -81,6 +83,7 @@ private static Object parseRecursive(JsonReader jr) throws IOException {
Map<String, Object> obj = new LinkedHashMap<>();
while (jr.hasNext()) {
String name = jr.nextName();
checkArgument(!obj.containsKey(name), "Duplicate key found: %s", name);
Object value = parseRecursive(jr);
obj.put(name, value);
}
Expand All @@ -105,4 +108,4 @@ private static Void parseJsonNull(JsonReader jr) throws IOException {
jr.nextNull();
return null;
}
}
}
190 changes: 189 additions & 1 deletion core/src/main/java/io/grpc/internal/SpiffeUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,42 @@
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.common.base.Optional;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
* Helper utility to work with SPIFFE URIs.
* Provides utilities to manage SPIFFE bundles, extract SPIFFE IDs from X.509 certificate chains,
* and parse SPIFFE IDs.
* @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
*/
public final class SpiffeUtil {

private static final Integer URI_SAN_TYPE = 6;
private static final String USE_PARAMETER_VALUE = "x509-svid";
private static final String KTY_PARAMETER_VALUE = "RSA";
private static final String CERTIFICATE_PREFIX = "-----BEGIN CERTIFICATE-----\n";
private static final String CERTIFICATE_SUFFIX = "-----END CERTIFICATE-----";
private static final String PREFIX = "spiffe://";

private SpiffeUtil() {}
Expand Down Expand Up @@ -96,6 +123,137 @@ private static void validatePathSegment(String pathSegment) {
+ " ([a-zA-Z0-9.-_])");
}

/**
* Returns the SPIFFE ID from the leaf certificate, if present.
*
* @param certChain certificate chain to extract SPIFFE ID from
*/
public static Optional<SpiffeId> extractSpiffeId(X509Certificate[] certChain)
throws CertificateParsingException {
checkArgument(checkNotNull(certChain, "certChain").length > 0, "certChain can't be empty");
Collection<List<?>> subjectAltNames = certChain[0].getSubjectAlternativeNames();
if (subjectAltNames == null) {
return Optional.absent();
}
String uri = null;
// Search for the unique URI SAN.
for (List<?> altName : subjectAltNames) {
if (altName.size() < 2 ) {
continue;
}
if (URI_SAN_TYPE.equals(altName.get(0))) {
if (uri != null) {
throw new IllegalArgumentException("Multiple URI SAN values found in the leaf cert.");
}
uri = (String) altName.get(1);
}
}
if (uri == null) {
return Optional.absent();
}
return Optional.of(parse(uri));
}

/**
* Loads a SPIFFE trust bundle from a file, parsing it from the JSON format.
* In case of success, returns {@link SpiffeBundle}.
* If any element of the JSON content is invalid or unsupported, an
* {@link IllegalArgumentException} is thrown and the entire Bundle is considered invalid.
*
* @param trustBundleFile the file path to the JSON file containing the trust bundle
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md">JSON format</a>
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#61-publishing-spiffe-bundle-elements">JWK entry format</a>
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7517#appendix-B">x5c (certificate) parameter</a>
*/
public static SpiffeBundle loadTrustBundleFromFile(String trustBundleFile) throws IOException {
Map<String, ?> trustDomainsNode = readTrustDomainsFromFile(trustBundleFile);
Map<String, List<X509Certificate>> trustBundleMap = new HashMap<>();
Map<String, Long> sequenceNumbers = new HashMap<>();
for (String trustDomainName : trustDomainsNode.keySet()) {
Map<String, ?> domainNode = JsonUtil.getObject(trustDomainsNode, trustDomainName);
if (domainNode.size() == 0) {
trustBundleMap.put(trustDomainName, Collections.emptyList());
continue;
}
Long sequenceNumber = JsonUtil.getNumberAsLong(domainNode, "spiffe_sequence");
sequenceNumbers.put(trustDomainName, sequenceNumber == null ? -1L : sequenceNumber);
List<Map<String, ?>> keysNode = JsonUtil.getListOfObjects(domainNode, "keys");
if (keysNode == null || keysNode.size() == 0) {
trustBundleMap.put(trustDomainName, Collections.emptyList());
continue;
}
trustBundleMap.put(trustDomainName, extractCert(keysNode, trustDomainName));
}
return new SpiffeBundle(sequenceNumbers, trustBundleMap);
}

private static Map<String, ?> readTrustDomainsFromFile(String filePath) throws IOException {
Path path = Paths.get(checkNotNull(filePath, "trustBundleFile"));
String json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
Object jsonObject = JsonParser.parse(json);
if (!(jsonObject instanceof Map)) {
throw new IllegalArgumentException(
"SPIFFE Trust Bundle should be a JSON object. Found: "
+ (jsonObject == null ? null : jsonObject.getClass()));
}
@SuppressWarnings("unchecked")
Map<String, ?> root = (Map<String, ?>)jsonObject;
Map<String, ?> trustDomainsNode = JsonUtil.getObject(root, "trust_domains");
checkNotNull(trustDomainsNode, "Mandatory trust_domains element is missing");
checkArgument(trustDomainsNode.size() > 0, "Mandatory trust_domains element is missing");
return trustDomainsNode;
}

private static void checkJwkEntry(Map<String, ?> jwkNode, String trustDomainName) {
String kty = JsonUtil.getString(jwkNode, "kty");
if (kty == null || !kty.equals(KTY_PARAMETER_VALUE)) {
throw new IllegalArgumentException(String.format("'kty' parameter must be '%s' but '%s' "
+ "found. Certificate loading for trust domain '%s' failed.", KTY_PARAMETER_VALUE,
kty, trustDomainName));
}
if (jwkNode.containsKey("kid")) {
throw new IllegalArgumentException(String.format("'kid' parameter must not be set. "
+ "Certificate loading for trust domain '%s' failed.", trustDomainName));
}
String use = JsonUtil.getString(jwkNode, "use");
if (use == null || !use.equals(USE_PARAMETER_VALUE)) {
throw new IllegalArgumentException(String.format("'use' parameter must be '%s' but '%s' "
+ "found. Certificate loading for trust domain '%s' failed.", USE_PARAMETER_VALUE,
use, trustDomainName));
}
}

private static List<X509Certificate> extractCert(List<Map<String, ?>> keysNode,
String trustDomainName) {
List<X509Certificate> result = new ArrayList<>();
for (Map<String, ?> keyNode : keysNode) {
checkJwkEntry(keyNode, trustDomainName);
List<String> rawCerts = JsonUtil.getListOfStrings(keyNode, "x5c");
if (rawCerts == null) {
break;
}
if (rawCerts.size() != 1) {
throw new IllegalArgumentException(String.format("Exactly 1 certificate is expected, but "
+ "%s found. Certificate loading for trust domain '%s' failed.", rawCerts.size(),
trustDomainName));
}
InputStream stream = new ByteArrayInputStream((CERTIFICATE_PREFIX + rawCerts.get(0) + "\n"
+ CERTIFICATE_SUFFIX)
.getBytes(StandardCharsets.UTF_8));
try {
Collection<? extends Certificate> certs = CertificateFactory.getInstance("X509")
.generateCertificates(stream);
X509Certificate[] certsArray = certs.toArray(new X509Certificate[0]);
assert certsArray.length == 1;
result.add(certsArray[0]);
} catch (CertificateException e) {
throw new IllegalArgumentException(String.format("Certificate can't be parsed. Certificate "
+ "loading for trust domain '%s' failed.", trustDomainName), e);
}
}
return result;
}

/**
* Represents a SPIFFE ID as defined in the SPIFFE standard.
* @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
Expand All @@ -119,4 +277,34 @@ public String getPath() {
}
}

/**
* Represents a SPIFFE trust bundle; that is, a map from trust domain to set of trusted
* certificates. Only trust domain's sequence numbers and x509 certificates are supported.
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format">Standard</a>
*/
public static final class SpiffeBundle {

private final ImmutableMap<String, Long> sequenceNumbers;

private final ImmutableMap<String, ImmutableList<X509Certificate>> bundleMap;

private SpiffeBundle(Map<String, Long> sequenceNumbers,
Map<String, List<X509Certificate>> trustDomainMap) {
this.sequenceNumbers = ImmutableMap.copyOf(sequenceNumbers);
ImmutableMap.Builder<String, ImmutableList<X509Certificate>> builder = ImmutableMap.builder();
for (Map.Entry<String, List<X509Certificate>> entry : trustDomainMap.entrySet()) {
builder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue()));
}
this.bundleMap = builder.build();
}

public ImmutableMap<String, Long> getSequenceNumbers() {
return sequenceNumbers;
}

public ImmutableMap<String, ImmutableList<X509Certificate>> getBundleMap() {
return bundleMap;
}
}

}
9 changes: 8 additions & 1 deletion core/src/test/java/io/grpc/internal/JsonParserTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,11 @@ public void objectStringName() throws IOException {

assertEquals(expected, JsonParser.parse("{\"hi\": 2}"));
}
}

@Test
public void duplicate() throws IOException {
thrown.expect(IllegalArgumentException.class);

JsonParser.parse("{\"hi\": 2, \"hi\": 3}");
}
}
Loading

0 comments on commit 4be69e3

Please sign in to comment.