Skip to content

Commit 4be69e3

Browse files
authoredOct 17, 2024··
core: SpiffeUtil API for extracting Spiffe URI and loading TrustBundles (#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
1 parent 1e0928f commit 4be69e3

18 files changed

+725
-7
lines changed
 

‎core/src/main/java/io/grpc/internal/JsonParser.java

+5-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.grpc.internal;
1818

19+
import static com.google.common.base.Preconditions.checkArgument;
1920
import static com.google.common.base.Preconditions.checkState;
2021

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

4243
/**
4344
* Parses a json string, returning either a {@code Map<String, ?>}, {@code List<?>},
44-
* {@code String}, {@code Double}, {@code Boolean}, or {@code null}.
45+
* {@code String}, {@code Double}, {@code Boolean}, or {@code null}. Fails if duplicate names
46+
* found.
4547
*/
4648
public static Object parse(String raw) throws IOException {
4749
JsonReader jr = new JsonReader(new StringReader(raw));
@@ -81,6 +83,7 @@ private static Object parseRecursive(JsonReader jr) throws IOException {
8183
Map<String, Object> obj = new LinkedHashMap<>();
8284
while (jr.hasNext()) {
8385
String name = jr.nextName();
86+
checkArgument(!obj.containsKey(name), "Duplicate key found: %s", name);
8487
Object value = parseRecursive(jr);
8588
obj.put(name, value);
8689
}
@@ -105,4 +108,4 @@ private static Void parseJsonNull(JsonReader jr) throws IOException {
105108
jr.nextNull();
106109
return null;
107110
}
108-
}
111+
}

‎core/src/main/java/io/grpc/internal/SpiffeUtil.java

+189-1
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,42 @@
1919
import static com.google.common.base.Preconditions.checkArgument;
2020
import static com.google.common.base.Preconditions.checkNotNull;
2121

22+
import com.google.common.base.Optional;
2223
import com.google.common.base.Splitter;
24+
import com.google.common.collect.ImmutableList;
25+
import com.google.common.collect.ImmutableMap;
26+
import java.io.ByteArrayInputStream;
27+
import java.io.IOException;
28+
import java.io.InputStream;
29+
import java.nio.charset.StandardCharsets;
30+
import java.nio.file.Files;
31+
import java.nio.file.Path;
32+
import java.nio.file.Paths;
33+
import java.security.cert.Certificate;
34+
import java.security.cert.CertificateException;
35+
import java.security.cert.CertificateFactory;
36+
import java.security.cert.CertificateParsingException;
37+
import java.security.cert.X509Certificate;
38+
import java.util.ArrayList;
39+
import java.util.Collection;
40+
import java.util.Collections;
41+
import java.util.HashMap;
42+
import java.util.List;
2343
import java.util.Locale;
44+
import java.util.Map;
2445

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

53+
private static final Integer URI_SAN_TYPE = 6;
54+
private static final String USE_PARAMETER_VALUE = "x509-svid";
55+
private static final String KTY_PARAMETER_VALUE = "RSA";
56+
private static final String CERTIFICATE_PREFIX = "-----BEGIN CERTIFICATE-----\n";
57+
private static final String CERTIFICATE_SUFFIX = "-----END CERTIFICATE-----";
3158
private static final String PREFIX = "spiffe://";
3259

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

126+
/**
127+
* Returns the SPIFFE ID from the leaf certificate, if present.
128+
*
129+
* @param certChain certificate chain to extract SPIFFE ID from
130+
*/
131+
public static Optional<SpiffeId> extractSpiffeId(X509Certificate[] certChain)
132+
throws CertificateParsingException {
133+
checkArgument(checkNotNull(certChain, "certChain").length > 0, "certChain can't be empty");
134+
Collection<List<?>> subjectAltNames = certChain[0].getSubjectAlternativeNames();
135+
if (subjectAltNames == null) {
136+
return Optional.absent();
137+
}
138+
String uri = null;
139+
// Search for the unique URI SAN.
140+
for (List<?> altName : subjectAltNames) {
141+
if (altName.size() < 2 ) {
142+
continue;
143+
}
144+
if (URI_SAN_TYPE.equals(altName.get(0))) {
145+
if (uri != null) {
146+
throw new IllegalArgumentException("Multiple URI SAN values found in the leaf cert.");
147+
}
148+
uri = (String) altName.get(1);
149+
}
150+
}
151+
if (uri == null) {
152+
return Optional.absent();
153+
}
154+
return Optional.of(parse(uri));
155+
}
156+
157+
/**
158+
* Loads a SPIFFE trust bundle from a file, parsing it from the JSON format.
159+
* In case of success, returns {@link SpiffeBundle}.
160+
* If any element of the JSON content is invalid or unsupported, an
161+
* {@link IllegalArgumentException} is thrown and the entire Bundle is considered invalid.
162+
*
163+
* @param trustBundleFile the file path to the JSON file containing the trust bundle
164+
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md">JSON format</a>
165+
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md#61-publishing-spiffe-bundle-elements">JWK entry format</a>
166+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc7517#appendix-B">x5c (certificate) parameter</a>
167+
*/
168+
public static SpiffeBundle loadTrustBundleFromFile(String trustBundleFile) throws IOException {
169+
Map<String, ?> trustDomainsNode = readTrustDomainsFromFile(trustBundleFile);
170+
Map<String, List<X509Certificate>> trustBundleMap = new HashMap<>();
171+
Map<String, Long> sequenceNumbers = new HashMap<>();
172+
for (String trustDomainName : trustDomainsNode.keySet()) {
173+
Map<String, ?> domainNode = JsonUtil.getObject(trustDomainsNode, trustDomainName);
174+
if (domainNode.size() == 0) {
175+
trustBundleMap.put(trustDomainName, Collections.emptyList());
176+
continue;
177+
}
178+
Long sequenceNumber = JsonUtil.getNumberAsLong(domainNode, "spiffe_sequence");
179+
sequenceNumbers.put(trustDomainName, sequenceNumber == null ? -1L : sequenceNumber);
180+
List<Map<String, ?>> keysNode = JsonUtil.getListOfObjects(domainNode, "keys");
181+
if (keysNode == null || keysNode.size() == 0) {
182+
trustBundleMap.put(trustDomainName, Collections.emptyList());
183+
continue;
184+
}
185+
trustBundleMap.put(trustDomainName, extractCert(keysNode, trustDomainName));
186+
}
187+
return new SpiffeBundle(sequenceNumbers, trustBundleMap);
188+
}
189+
190+
private static Map<String, ?> readTrustDomainsFromFile(String filePath) throws IOException {
191+
Path path = Paths.get(checkNotNull(filePath, "trustBundleFile"));
192+
String json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
193+
Object jsonObject = JsonParser.parse(json);
194+
if (!(jsonObject instanceof Map)) {
195+
throw new IllegalArgumentException(
196+
"SPIFFE Trust Bundle should be a JSON object. Found: "
197+
+ (jsonObject == null ? null : jsonObject.getClass()));
198+
}
199+
@SuppressWarnings("unchecked")
200+
Map<String, ?> root = (Map<String, ?>)jsonObject;
201+
Map<String, ?> trustDomainsNode = JsonUtil.getObject(root, "trust_domains");
202+
checkNotNull(trustDomainsNode, "Mandatory trust_domains element is missing");
203+
checkArgument(trustDomainsNode.size() > 0, "Mandatory trust_domains element is missing");
204+
return trustDomainsNode;
205+
}
206+
207+
private static void checkJwkEntry(Map<String, ?> jwkNode, String trustDomainName) {
208+
String kty = JsonUtil.getString(jwkNode, "kty");
209+
if (kty == null || !kty.equals(KTY_PARAMETER_VALUE)) {
210+
throw new IllegalArgumentException(String.format("'kty' parameter must be '%s' but '%s' "
211+
+ "found. Certificate loading for trust domain '%s' failed.", KTY_PARAMETER_VALUE,
212+
kty, trustDomainName));
213+
}
214+
if (jwkNode.containsKey("kid")) {
215+
throw new IllegalArgumentException(String.format("'kid' parameter must not be set. "
216+
+ "Certificate loading for trust domain '%s' failed.", trustDomainName));
217+
}
218+
String use = JsonUtil.getString(jwkNode, "use");
219+
if (use == null || !use.equals(USE_PARAMETER_VALUE)) {
220+
throw new IllegalArgumentException(String.format("'use' parameter must be '%s' but '%s' "
221+
+ "found. Certificate loading for trust domain '%s' failed.", USE_PARAMETER_VALUE,
222+
use, trustDomainName));
223+
}
224+
}
225+
226+
private static List<X509Certificate> extractCert(List<Map<String, ?>> keysNode,
227+
String trustDomainName) {
228+
List<X509Certificate> result = new ArrayList<>();
229+
for (Map<String, ?> keyNode : keysNode) {
230+
checkJwkEntry(keyNode, trustDomainName);
231+
List<String> rawCerts = JsonUtil.getListOfStrings(keyNode, "x5c");
232+
if (rawCerts == null) {
233+
break;
234+
}
235+
if (rawCerts.size() != 1) {
236+
throw new IllegalArgumentException(String.format("Exactly 1 certificate is expected, but "
237+
+ "%s found. Certificate loading for trust domain '%s' failed.", rawCerts.size(),
238+
trustDomainName));
239+
}
240+
InputStream stream = new ByteArrayInputStream((CERTIFICATE_PREFIX + rawCerts.get(0) + "\n"
241+
+ CERTIFICATE_SUFFIX)
242+
.getBytes(StandardCharsets.UTF_8));
243+
try {
244+
Collection<? extends Certificate> certs = CertificateFactory.getInstance("X509")
245+
.generateCertificates(stream);
246+
X509Certificate[] certsArray = certs.toArray(new X509Certificate[0]);
247+
assert certsArray.length == 1;
248+
result.add(certsArray[0]);
249+
} catch (CertificateException e) {
250+
throw new IllegalArgumentException(String.format("Certificate can't be parsed. Certificate "
251+
+ "loading for trust domain '%s' failed.", trustDomainName), e);
252+
}
253+
}
254+
return result;
255+
}
256+
99257
/**
100258
* Represents a SPIFFE ID as defined in the SPIFFE standard.
101259
* @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
@@ -119,4 +277,34 @@ public String getPath() {
119277
}
120278
}
121279

280+
/**
281+
* Represents a SPIFFE trust bundle; that is, a map from trust domain to set of trusted
282+
* certificates. Only trust domain's sequence numbers and x509 certificates are supported.
283+
* @see <a href="https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format">Standard</a>
284+
*/
285+
public static final class SpiffeBundle {
286+
287+
private final ImmutableMap<String, Long> sequenceNumbers;
288+
289+
private final ImmutableMap<String, ImmutableList<X509Certificate>> bundleMap;
290+
291+
private SpiffeBundle(Map<String, Long> sequenceNumbers,
292+
Map<String, List<X509Certificate>> trustDomainMap) {
293+
this.sequenceNumbers = ImmutableMap.copyOf(sequenceNumbers);
294+
ImmutableMap.Builder<String, ImmutableList<X509Certificate>> builder = ImmutableMap.builder();
295+
for (Map.Entry<String, List<X509Certificate>> entry : trustDomainMap.entrySet()) {
296+
builder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue()));
297+
}
298+
this.bundleMap = builder.build();
299+
}
300+
301+
public ImmutableMap<String, Long> getSequenceNumbers() {
302+
return sequenceNumbers;
303+
}
304+
305+
public ImmutableMap<String, ImmutableList<X509Certificate>> getBundleMap() {
306+
return bundleMap;
307+
}
308+
}
309+
122310
}

‎core/src/test/java/io/grpc/internal/JsonParserTest.java

+8-1
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,11 @@ public void objectStringName() throws IOException {
123123

124124
assertEquals(expected, JsonParser.parse("{\"hi\": 2}"));
125125
}
126-
}
126+
127+
@Test
128+
public void duplicate() throws IOException {
129+
thrown.expect(IllegalArgumentException.class);
130+
131+
JsonParser.parse("{\"hi\": 2, \"hi\": 3}");
132+
}
133+
}

0 commit comments

Comments
 (0)
Please sign in to comment.