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

grpc / grpc-java / #19514

17 Oct 2024 06:11PM UTC coverage: 84.69% (+0.05%) from 84.645%
#19514

push

github

web-flow
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

33877 of 40001 relevant lines covered (84.69%)

0.85 hits per line

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

96.03
/../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 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;
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 String KTY_PARAMETER_VALUE = "RSA";
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();
×
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
    Path path = Paths.get(checkNotNull(filePath, "trustBundleFile"));
1✔
192
    String json = new String(Files.readAllBytes(path), 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.equals(KTY_PARAMETER_VALUE)) {
1✔
210
      throw new IllegalArgumentException(String.format("'kty' parameter must be '%s' but '%s' "
1✔
211
              + "found. Certificate loading for trust domain '%s' failed.", KTY_PARAMETER_VALUE,
212
          kty, trustDomainName));
213
    }
214
    if (jwkNode.containsKey("kid")) {
1✔
215
      throw new IllegalArgumentException(String.format("'kid' parameter must not be set. "
1✔
216
              + "Certificate loading for trust domain '%s' failed.", trustDomainName));
217
    }
218
    String use = JsonUtil.getString(jwkNode, "use");
1✔
219
    if (use == null || !use.equals(USE_PARAMETER_VALUE)) {
1✔
220
      throw new IllegalArgumentException(String.format("'use' parameter must be '%s' but '%s' "
1✔
221
              + "found. Certificate loading for trust domain '%s' failed.", USE_PARAMETER_VALUE,
222
          use, trustDomainName));
223
    }
224
  }
1✔
225

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

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

263
    private final String trustDomain;
264
    private final String path;
265

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

271
    public String getTrustDomain() {
272
      return trustDomain;
1✔
273
    }
274

275
    public String getPath() {
276
      return path;
1✔
277
    }
278
  }
279

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) {
1✔
293
      this.sequenceNumbers = ImmutableMap.copyOf(sequenceNumbers);
1✔
294
      ImmutableMap.Builder<String, ImmutableList<X509Certificate>> builder = ImmutableMap.builder();
1✔
295
      for (Map.Entry<String, List<X509Certificate>> entry : trustDomainMap.entrySet()) {
1✔
296
        builder.put(entry.getKey(), ImmutableList.copyOf(entry.getValue()));
1✔
297
      }
1✔
298
      this.bundleMap = builder.build();
1✔
299
    }
1✔
300

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

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

310
}
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