Skip to content

core: SpiffeUtil API for extracting Spiffe URI and loading TrustBundles #11575

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Enhance for JsonParser, API for loading trust bundle from file and ex…
…traction of SPIFFE uri from leaf certs
  • Loading branch information
erm-g committed Sep 30, 2024
commit 59b0409c508c115c5d01a8d3d2bae87f8faa58fc
41 changes: 32 additions & 9 deletions core/src/main/java/io/grpc/internal/JsonParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ private JsonParser() {}
public static Object parse(String raw) throws IOException {
JsonReader jr = new JsonReader(new StringReader(raw));
try {
return parseRecursive(jr);
return parseRecursive(jr, false);
} finally {
try {
jr.close();
Expand All @@ -56,13 +56,32 @@ public static Object parse(String raw) throws IOException {
}
}

private static Object parseRecursive(JsonReader jr) throws IOException {
/**
* Parses a json string, returning either a {@code Map<String, ?>}, {@code List<?>},
* {@code String}, {@code Double}, {@code Boolean}, or {@code null}. Fails if duplicate names
* found.
*/
public static Object parseNoDuplicates(String raw) throws IOException {
JsonReader jr = new JsonReader(new StringReader(raw));
try {
return parseRecursive(jr, true);
} finally {
try {
jr.close();
} catch (IOException e) {
logger.log(Level.WARNING, "Failed to close", e);
}
}
}

private static Object parseRecursive(JsonReader jr, Boolean... failOnDuplicates)
throws IOException {
checkState(jr.hasNext(), "unexpected end of JSON");
switch (jr.peek()) {
case BEGIN_ARRAY:
return parseJsonArray(jr);
return parseJsonArray(jr, failOnDuplicates);
case BEGIN_OBJECT:
return parseJsonObject(jr);
return parseJsonObject(jr, failOnDuplicates);
case STRING:
return jr.nextString();
case NUMBER:
Expand All @@ -76,24 +95,28 @@ private static Object parseRecursive(JsonReader jr) throws IOException {
}
}

private static Map<String, ?> parseJsonObject(JsonReader jr) throws IOException {
private static Map<String, ?> parseJsonObject(JsonReader jr, Boolean... failOnDuplicates)
throws IOException {
jr.beginObject();
Map<String, Object> obj = new LinkedHashMap<>();
while (jr.hasNext()) {
String name = jr.nextName();
Object value = parseRecursive(jr);
if (failOnDuplicates.length > 0 && failOnDuplicates[0] && obj.containsKey(name)) {
throw new IllegalArgumentException("Duplicate key found: " + name);
}
Object value = parseRecursive(jr, failOnDuplicates);
obj.put(name, value);
}
checkState(jr.peek() == JsonToken.END_OBJECT, "Bad token: " + jr.getPath());
jr.endObject();
return Collections.unmodifiableMap(obj);
}

private static List<?> parseJsonArray(JsonReader jr) throws IOException {
private static List<?> parseJsonArray(JsonReader jr, Boolean... params) throws IOException {
jr.beginArray();
List<Object> array = new ArrayList<>();
while (jr.hasNext()) {
Object value = parseRecursive(jr);
Object value = parseRecursive(jr, params);
array.add(value);
}
checkState(jr.peek() == JsonToken.END_ARRAY, "Bad token: " + jr.getPath());
Expand All @@ -105,4 +128,4 @@ private static Void parseJsonNull(JsonReader jr) throws IOException {
jr.nextNull();
return null;
}
}
}
193 changes: 192 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,43 @@
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;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Helper utility to work with SPIFFE URIs.
* Provides utilities to load, extract, and parse SPIFFE ID according the SPIFFE ID standard.
* @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
*/
public final class SpiffeUtil {

private static final Logger log = Logger.getLogger(SpiffeUtil.class.getName());

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 PREFIX = "spiffe://";

private SpiffeUtil() {}
Expand Down Expand Up @@ -96,6 +124,139 @@ 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;
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 trust domains, their associated sequence numbers, and X.509
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two minor presentation comments:

  1. Rather than redefining a SPIFFE bundle, I think it suffices to say we return a SPIFFE bundle. :)
  2. Please clarify that, if any "key" in the trust map is deemed to be invalid or unsupported", we log and drop it from the bundle, but do not fail overall.

* certificates.
*
* @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>
*/
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 == null || domainNode.size() == 0) {
trustBundleMap.put(trustDomainName, Collections.emptyList());
continue;
}
Long sequenceNumber = JsonUtil.getNumberAsLong(domainNode, "sequence_number");
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.parseNoDuplicates(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");
checkArgument(checkNotNull(trustDomainsNode,
"Mandatory trust_domains element is missing").size() > 0,
"Mandatory trust_domains element is missing");
return trustDomainsNode;
}

private static boolean checkJwkEntry(Map<String, ?> jwkNode, String trustDomainName) {
String kty = JsonUtil.getString(jwkNode, "kty");
if (kty == null || !kty.equals(KTY_PARAMETER_VALUE)) {
log.log(Level.SEVERE, String.format("'kty' parameter must be '%s' but '%s' found. "
+ "Skipping certificate loading for trust domain '%s'.", KTY_PARAMETER_VALUE, kty,
trustDomainName));
return false;
}
String kid = JsonUtil.getString(jwkNode, "kid");
if (kid != null && !kid.equals("")) {
log.log(Level.SEVERE, String.format("'kid' parameter must not be set but value '%s' "
+ "found. Skipping certificate loading for trust domain '%s'.", kid,
trustDomainName));
return false;
}
String use = JsonUtil.getString(jwkNode, "use");
if (use == null || !use.equals(USE_PARAMETER_VALUE)) {
log.log(Level.SEVERE, String.format("'use' parameter must be '%s' but '%s' found. "
+ "Skipping certificate loading for trust domain '%s'.", USE_PARAMETER_VALUE, use,
trustDomainName));
return false;
}
return true;
}

private static List<X509Certificate> extractCert(List<Map<String, ?>> keysNode,
String trustDomainName) {
List<X509Certificate> result = new ArrayList<>();
for (Map<String, ?> keyNode : keysNode) {
if (!checkJwkEntry(keyNode, trustDomainName)) {
break;
}
String rawCert = JsonUtil.getString(keyNode, "x5c");
if (rawCert == null) {
break;
}
InputStream stream = new ByteArrayInputStream(rawCert.getBytes(StandardCharsets.UTF_8));
try {
Collection<? extends Certificate> certs = CertificateFactory.getInstance("X509")
.generateCertificates(stream);
if (certs.size() != 1) {
log.log(Level.SEVERE, String.format("Exactly 1 certificate is expected, but %s found for "
+ "domain %s.", certs.size(), trustDomainName));
} else {
result.add(certs.toArray(new X509Certificate[0])[0]);
}
} catch (CertificateException e) {
log.log(Level.SEVERE, String.format("Certificate for domain %s can't be parsed.",
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 +280,34 @@ public String getPath() {
}
}

/**
* Represents a Trust Bundle inspired by SPIFFE Bundle Format standard. Only trust domain's
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested edit for clarity: Represents a SPIFFE trust bundle; that is, a map from trust domain to set of trusted certificates.

* 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;

public 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.parseNoDuplicates("{\"hi\": 2, \"hi\": 3}");
}
}
Loading
Loading