• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

grpc / grpc-java / #20019

17 Oct 2025 09:17AM UTC coverage: 88.601% (+0.01%) from 88.59%
#20019

push

github

web-flow
internal: Allow EC Keys in SPIFFE Bundle Map parsing (#12399)

SPIFFE Bundle Map parsing was originally implemented to only support RSA keys. It should also support EC keys.

34939 of 39434 relevant lines covered (88.6%)

0.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.88
/../core/src/main/java/io/grpc/internal/SpiffeUtil.java
1
/*
2
 * Copyright 2024 The gRPC Authors
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16

17
package io.grpc.internal;
18

19
import static com.google.common.base.Preconditions.checkArgument;
20
import static com.google.common.base.Preconditions.checkNotNull;
21

22
import com.google.common.base.Optional;
23
import com.google.common.base.Splitter;
24
import com.google.common.collect.ImmutableList;
25
import com.google.common.collect.ImmutableMap;
26
import com.google.common.collect.ImmutableSet;
27
import com.google.common.io.Files;
28
import java.io.ByteArrayInputStream;
29
import java.io.File;
30
import java.io.IOException;
31
import java.io.InputStream;
32
import java.nio.charset.StandardCharsets;
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;
43
import java.util.Locale;
44
import java.util.Map;
45

46
/**
47
 * Provides utilities to manage SPIFFE bundles, extract SPIFFE IDs from X.509 certificate chains,
48
 * and parse SPIFFE IDs.
49
 * @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
50
 */
51
public final class SpiffeUtil {
52

53
  private static final Integer URI_SAN_TYPE = 6;
1✔
54
  private static final String USE_PARAMETER_VALUE = "x509-svid";
55
  private static final ImmutableSet<String> KTY_PARAMETER_VALUES = ImmutableSet.of("RSA", "EC");
1✔
56
  private static final String CERTIFICATE_PREFIX = "-----BEGIN CERTIFICATE-----\n";
57
  private static final String CERTIFICATE_SUFFIX = "-----END CERTIFICATE-----";
58
  private static final String PREFIX = "spiffe://";
59

60
  private SpiffeUtil() {}
61

62
  /**
63
   * Parses a URI string, applies validation rules described in SPIFFE standard, and, in case of
64
   * success, returns parsed TrustDomain and Path.
65
   *
66
   * @param uri a String representing a SPIFFE ID
67
   */
68
  public static SpiffeId parse(String uri) {
69
    doInitialUriValidation(uri);
1✔
70
    checkArgument(uri.toLowerCase(Locale.US).startsWith(PREFIX), "Spiffe Id must start with "
1✔
71
        + PREFIX);
72
    String domainAndPath = uri.substring(PREFIX.length());
1✔
73
    String trustDomain;
74
    String path;
75
    if (!domainAndPath.contains("/")) {
1✔
76
      trustDomain = domainAndPath;
1✔
77
      path =  "";
1✔
78
    } else {
79
      String[] parts = domainAndPath.split("/", 2);
1✔
80
      trustDomain = parts[0];
1✔
81
      path = parts[1];
1✔
82
      checkArgument(!path.isEmpty(), "Path must not include a trailing '/'");
1✔
83
    }
84
    validateTrustDomain(trustDomain);
1✔
85
    validatePath(path);
1✔
86
    if (!path.isEmpty()) {
1✔
87
      path = "/" + path;
1✔
88
    }
89
    return new SpiffeId(trustDomain, path);
1✔
90
  }
91

92
  private static void doInitialUriValidation(String uri) {
93
    checkArgument(checkNotNull(uri, "uri").length() > 0, "Spiffe Id can't be empty");
1✔
94
    checkArgument(uri.length() <= 2048, "Spiffe Id maximum length is 2048 characters");
1✔
95
    checkArgument(!uri.contains("#"), "Spiffe Id must not contain query fragments");
1✔
96
    checkArgument(!uri.contains("?"), "Spiffe Id must not contain query parameters");
1✔
97
  }
1✔
98

99
  private static void validateTrustDomain(String trustDomain) {
100
    checkArgument(!trustDomain.isEmpty(), "Trust Domain can't be empty");
1✔
101
    checkArgument(trustDomain.length() < 256, "Trust Domain maximum length is 255 characters");
1✔
102
    checkArgument(trustDomain.matches("[a-z0-9._-]+"),
1✔
103
        "Trust Domain must contain only letters, numbers, dots, dashes, and underscores"
104
            + " ([a-z0-9.-_])");
105
  }
1✔
106

107
  private static void validatePath(String path) {
108
    if (path.isEmpty()) {
1✔
109
      return;
1✔
110
    }
111
    checkArgument(!path.endsWith("/"), "Path must not include a trailing '/'");
1✔
112
    for (String segment : Splitter.on("/").split(path)) {
1✔
113
      validatePathSegment(segment);
1✔
114
    }
1✔
115
  }
1✔
116

117
  private static void validatePathSegment(String pathSegment) {
118
    checkArgument(!pathSegment.isEmpty(), "Individual path segments must not be empty");
1✔
119
    checkArgument(!(pathSegment.equals(".") || pathSegment.equals("..")),
1✔
120
        "Individual path segments must not be relative path modifiers (i.e. ., ..)");
121
    checkArgument(pathSegment.matches("[a-zA-Z0-9._-]+"),
1✔
122
        "Individual path segments must contain only letters, numbers, dots, dashes, and underscores"
123
            + " ([a-zA-Z0-9.-_])");
124
  }
1✔
125

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");
1✔
134
    Collection<List<?>> subjectAltNames = certChain[0].getSubjectAlternativeNames();
1✔
135
    if (subjectAltNames == null) {
1✔
136
      return Optional.absent();
1✔
137
    }
138
    String uri = null;
1✔
139
    // Search for the unique URI SAN.
140
    for (List<?> altName : subjectAltNames) {
1✔
141
      if (altName.size() < 2 ) {
1✔
142
        continue;
×
143
      }
144
      if (URI_SAN_TYPE.equals(altName.get(0))) {
1✔
145
        if (uri != null) {
1✔
146
          throw new IllegalArgumentException("Multiple URI SAN values found in the leaf cert.");
1✔
147
        }
148
        uri = (String) altName.get(1);
1✔
149
      }
150
    }
1✔
151
    if (uri == null) {
1✔
152
      return Optional.absent();
1✔
153
    }
154
    return Optional.of(parse(uri));
1✔
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);
1✔
170
    Map<String, List<X509Certificate>> trustBundleMap = new HashMap<>();
1✔
171
    Map<String, Long> sequenceNumbers = new HashMap<>();
1✔
172
    for (String trustDomainName : trustDomainsNode.keySet()) {
1✔
173
      Map<String, ?> domainNode = JsonUtil.getObject(trustDomainsNode, trustDomainName);
1✔
174
      if (domainNode.size() == 0) {
1✔
175
        trustBundleMap.put(trustDomainName, Collections.emptyList());
1✔
176
        continue;
1✔
177
      }
178
      Long sequenceNumber = JsonUtil.getNumberAsLong(domainNode, "spiffe_sequence");
1✔
179
      sequenceNumbers.put(trustDomainName, sequenceNumber == null ? -1L : sequenceNumber);
1✔
180
      List<Map<String, ?>> keysNode = JsonUtil.getListOfObjects(domainNode, "keys");
1✔
181
      if (keysNode == null || keysNode.size() == 0) {
1✔
182
        trustBundleMap.put(trustDomainName, Collections.emptyList());
×
183
        continue;
×
184
      }
185
      trustBundleMap.put(trustDomainName, extractCert(keysNode, trustDomainName));
1✔
186
    }
1✔
187
    return new SpiffeBundle(sequenceNumbers, trustBundleMap);
1✔
188
  }
189

190
  private static Map<String, ?> readTrustDomainsFromFile(String filePath) throws IOException {
191
    File file = new File(checkNotNull(filePath, "trustBundleFile"));
1✔
192
    String json = new String(Files.toByteArray(file), StandardCharsets.UTF_8);
1✔
193
    Object jsonObject = JsonParser.parse(json);
1✔
194
    if (!(jsonObject instanceof Map)) {
1✔
195
      throw new IllegalArgumentException(
1✔
196
          "SPIFFE Trust Bundle should be a JSON object. Found: "
197
              + (jsonObject == null ? null : jsonObject.getClass()));
1✔
198
    }
199
    @SuppressWarnings("unchecked")
200
    Map<String, ?> root = (Map<String, ?>)jsonObject;
1✔
201
    Map<String, ?> trustDomainsNode = JsonUtil.getObject(root, "trust_domains");
1✔
202
    checkNotNull(trustDomainsNode, "Mandatory trust_domains element is missing");
1✔
203
    checkArgument(trustDomainsNode.size() > 0, "Mandatory trust_domains element is missing");
1✔
204
    return trustDomainsNode;
1✔
205
  }
206

207
  private static void checkJwkEntry(Map<String, ?> jwkNode, String trustDomainName) {
208
    String kty = JsonUtil.getString(jwkNode, "kty");
1✔
209
    if (kty == null || !KTY_PARAMETER_VALUES.contains(kty)) {
1✔
210
      throw new IllegalArgumentException(
1✔
211
          String.format(
1✔
212
              "'kty' parameter must be one of %s but '%s' "
213
                  + "found. Certificate loading for trust domain '%s' failed.",
214
              KTY_PARAMETER_VALUES, kty, trustDomainName));
215
    }
216
    if (jwkNode.containsKey("kid")) {
1✔
217
      throw new IllegalArgumentException(String.format("'kid' parameter must not be set. "
1✔
218
              + "Certificate loading for trust domain '%s' failed.", trustDomainName));
219
    }
220
    String use = JsonUtil.getString(jwkNode, "use");
1✔
221
    if (use == null || !use.equals(USE_PARAMETER_VALUE)) {
1✔
222
      throw new IllegalArgumentException(String.format("'use' parameter must be '%s' but '%s' "
1✔
223
              + "found. Certificate loading for trust domain '%s' failed.", USE_PARAMETER_VALUE,
224
          use, trustDomainName));
225
    }
226
  }
1✔
227

228
  private static List<X509Certificate> extractCert(List<Map<String, ?>> keysNode,
229
      String trustDomainName) {
230
    List<X509Certificate> result = new ArrayList<>();
1✔
231
    for (Map<String, ?> keyNode : keysNode) {
1✔
232
      checkJwkEntry(keyNode, trustDomainName);
1✔
233
      List<String> rawCerts = JsonUtil.getListOfStrings(keyNode, "x5c");
1✔
234
      if (rawCerts == null) {
1✔
235
        break;
×
236
      }
237
      if (rawCerts.size() != 1) {
1✔
238
        throw new IllegalArgumentException(String.format("Exactly 1 certificate is expected, but "
1✔
239
            + "%s found. Certificate loading for trust domain '%s' failed.", rawCerts.size(),
1✔
240
            trustDomainName));
241
      }
242
      InputStream stream = new ByteArrayInputStream((CERTIFICATE_PREFIX + rawCerts.get(0) + "\n"
1✔
243
          + CERTIFICATE_SUFFIX)
244
          .getBytes(StandardCharsets.UTF_8));
1✔
245
      try {
246
        Collection<? extends Certificate> certs = CertificateFactory.getInstance("X509")
1✔
247
            .generateCertificates(stream);
1✔
248
        X509Certificate[] certsArray = certs.toArray(new X509Certificate[0]);
1✔
249
        assert certsArray.length == 1;
1✔
250
        result.add(certsArray[0]);
1✔
251
      } catch (CertificateException e) {
1✔
252
        throw new IllegalArgumentException(String.format("Certificate can't be parsed. Certificate "
1✔
253
            + "loading for trust domain '%s' failed.", trustDomainName), e);
254
      }
1✔
255
    }
1✔
256
    return result;
1✔
257
  }
258

259
  /**
260
   * Represents a SPIFFE ID as defined in the SPIFFE standard.
261
   * @see <a href="https://github.com/spiffe/spiffe/blob/master/standards/SPIFFE-ID.md">Standard</a>
262
   */
263
  public static class SpiffeId {
264

265
    private final String trustDomain;
266
    private final String path;
267

268
    private SpiffeId(String trustDomain, String path) {
1✔
269
      this.trustDomain = trustDomain;
1✔
270
      this.path = path;
1✔
271
    }
1✔
272

273
    public String getTrustDomain() {
274
      return trustDomain;
1✔
275
    }
276

277
    public String getPath() {
278
      return path;
1✔
279
    }
280
  }
281

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

289
    private final ImmutableMap<String, Long> sequenceNumbers;
290

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

293
    private SpiffeBundle(Map<String, Long> sequenceNumbers,
294
        Map<String, List<X509Certificate>> trustDomainMap) {
1✔
295
      this.sequenceNumbers = ImmutableMap.copyOf(sequenceNumbers);
1✔
296
      ImmutableMap.Builder<String, ImmutableList<X509Certificate>> builder = ImmutableMap.builder();
1✔
297
      for (Map.Entry<String, List<X509Certificate>> entry : trustDomainMap.entrySet()) {
1✔
298
        builder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue()));
1✔
299
      }
1✔
300
      this.bundleMap = builder.build();
1✔
301
    }
1✔
302

303
    public ImmutableMap<String, Long> getSequenceNumbers() {
304
      return sequenceNumbers;
1✔
305
    }
306

307
    public ImmutableMap<String, ImmutableList<X509Certificate>> getBundleMap() {
308
      return bundleMap;
1✔
309
    }
310
  }
311

312
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc