core: SpiffeUtil API for extracting Spiffe URI and loading TrustBundl… · grpc/grpc-java@4be69e3
@@ -19,15 +19,42 @@
1919import static com.google.common.base.Preconditions.checkArgument;
2020import static com.google.common.base.Preconditions.checkNotNull;
212122+import com.google.common.base.Optional;
2223import 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;
2343import java.util.Locale;
44+import java.util.Map;
24452546/**
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 */
2951public final class SpiffeUtil {
305253+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-----";
3158private static final String PREFIX = "spiffe://";
32593360private SpiffeUtil() {}
@@ -96,6 +123,137 @@ private static void validatePathSegment(String pathSegment) {
96123 + " ([a-zA-Z0-9.-_])");
97124 }
98125126+/**
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 }
121279280+/**
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}