Skip to content

Commit 7261434

Browse files
authored
Merge pull request #473 from Yubico/mds-cache-headers
Use HTTP cache headers in MDS BLOB downloads
2 parents b42ecf9 + 19950d8 commit 7261434

3 files changed

Lines changed: 210 additions & 13 deletions

File tree

NEWS

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ New features:
3636
`cxpConfigURL`
3737
** `StatusReport`: `certificationProfiles`, `sunsetDate`, `fipsRevision`,
3838
`fipsPhysicalSecurityLevel`
39+
* `FidoMetadataDownloader` now sends the `If-None-Match` request header set to
40+
the `"no"` of the cached BLOB, if any, and handles `304 Not Modified`
41+
responses appropriately.
42+
* In `FidoMetadataDownloader` if a BLOB download request returns an HTTP failure
43+
status, but returns an `ETag` response header matching the `"no"` of the
44+
cached BLOB, if any, this is now interpreted like a successful `304 Not
45+
Modified` response.
3946

4047
Fixes:
4148

webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,14 @@ private Optional<MetadataBLOB> refreshBlobInternal(
876876

877877
try {
878878
log.debug("Attempting to download new BLOB...");
879-
final ByteArray downloadedBytes = download(blobUrl);
879+
final ByteArray downloadedBytes =
880+
download(
881+
blobUrl,
882+
// This should ideally use the value of the ETag response header from when the cached
883+
// BLOB was downloaded, but we don't have anywhere to store that without changing the
884+
// format of the cache serialization. This is good enough as the MDS explicitly
885+
// specifies that the ETag is set to the "no" of the BLOB.
886+
cached.map(cachedBlob -> String.format("%d", cachedBlob.getPayload().getNo())));
880887
final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot);
881888
log.debug("New BLOB downloaded.");
882889

@@ -906,6 +913,11 @@ private Optional<MetadataBLOB> refreshBlobInternal(
906913
}
907914

908915
return Optional.of(downloadedBlob);
916+
917+
} catch (NotModified e) {
918+
log.debug("Remote BLOB not modified - using cached BLOB.");
919+
return cached;
920+
909921
} catch (FidoMetadataDownloaderException e) {
910922
if (e.getReason() == Reason.BAD_SIGNATURE && cached.isPresent()) {
911923
log.warn("New BLOB has bad signature - falling back to cached BLOB.");
@@ -1062,6 +1074,17 @@ Optional<ByteArray> readCacheFile(File cacheFile) throws IOException {
10621074
}
10631075

10641076
private ByteArray download(URL url) throws IOException {
1077+
try {
1078+
return download(url, Optional.empty());
1079+
} catch (NotModified e) {
1080+
final String msg =
1081+
"NotModified thrown by download(URL, Optional.empty()). This should be impossible, please file a bug report.";
1082+
log.error(msg);
1083+
throw new RuntimeException(msg, e);
1084+
}
1085+
}
1086+
1087+
private ByteArray download(URL url, Optional<String> etag) throws IOException, NotModified {
10651088
URLConnection conn = url.openConnection();
10661089

10671090
if (conn instanceof HttpsURLConnection) {
@@ -1082,6 +1105,38 @@ private ByteArray download(URL url) throws IOException {
10821105
}
10831106
}
10841107
httpsConn.setRequestMethod("GET");
1108+
etag.ifPresent(
1109+
et -> {
1110+
httpsConn.addRequestProperty("If-None-Match", String.format("\"%s\"", et));
1111+
});
1112+
1113+
if (httpsConn.getResponseCode() != HttpsURLConnection.HTTP_OK) {
1114+
switch (httpsConn.getResponseCode()) {
1115+
case 304: // Not Modified
1116+
log.debug("Received 304 Not Modified response to download request: {}", url);
1117+
throw new NotModified();
1118+
}
1119+
1120+
log.warn(
1121+
"Received non-200 status: {} to download request: {}",
1122+
httpsConn.getResponseCode(),
1123+
url);
1124+
1125+
final String responseEtag = httpsConn.getHeaderField("ETag");
1126+
if (responseEtag != null) {
1127+
log.debug("Response ETag: {}", responseEtag);
1128+
if (etag.map(
1129+
et ->
1130+
// ETag header value should be wrapped with double quotes (`etag: "243"`), but
1131+
// FIDO MDS returns it like: `etag: 243`. Try both in case that changes in the
1132+
// future.
1133+
et.equals(responseEtag) || String.format("\"%s\"", et).equals(responseEtag))
1134+
.orElseGet(() -> false)) {
1135+
log.debug("Response ETag matches local ETag - interpreting as not modified.");
1136+
throw new NotModified();
1137+
}
1138+
}
1139+
}
10851140
}
10861141

10871142
return readAll(conn.getInputStream());
@@ -1321,4 +1376,6 @@ private Optional<CertStore> fetchCrlDistributionPoints(
13211376
CertStore.getInstance("Collection", new CollectionCertStoreParameters(crldpCrls)));
13221377
}
13231378
}
1379+
1380+
private static class NotModified extends Throwable {}
13241381
}

webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala

Lines changed: 145 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -219,17 +219,19 @@ class FidoMetadataDownloaderSpec
219219
private def makeHttpServer(
220220
path: String,
221221
response: String,
222+
inspectRequest: HttpServletRequest => Any = _ => {},
222223
): (Server, String, X509Certificate) =
223-
makeHttpServer(
224-
Map(path -> (200, response.getBytes(StandardCharsets.UTF_8)))
225-
)
224+
makeHttpServer(Map(path -> (request => {
225+
inspectRequest(request)
226+
(200, response.getBytes(StandardCharsets.UTF_8))
227+
})))
226228
private def makeHttpServer(
227229
path: String,
228230
response: Array[Byte],
229231
): (Server, String, X509Certificate) =
230-
makeHttpServer(Map(path -> (200, response)))
232+
makeHttpServer(Map(path -> (_ => (200, response))))
231233
private def makeHttpServer(
232-
responses: Map[String, (Int, Array[Byte])]
234+
responses: Map[String, HttpServletRequest => (Int, Array[Byte])]
233235
): (Server, String, X509Certificate) = {
234236
val tlsKey = TestAuthenticator.generateEcKeypair()
235237
val tlsCert = TestAuthenticator.buildCertificate(
@@ -271,8 +273,10 @@ class FidoMetadataDownloaderSpec
271273
request: HttpServletRequest,
272274
response: HttpServletResponse,
273275
): Unit = {
276+
274277
responses.get(target) match {
275-
case Some((status, responseBody)) => {
278+
case Some(processRequest) => {
279+
val (status, responseBody) = processRequest(request)
276280
response.getOutputStream.write(responseBody)
277281
response.setStatus(status)
278282
}
@@ -1119,8 +1123,13 @@ class FidoMetadataDownloaderSpec
11191123
val (server, serverUrl, httpsCert) =
11201124
makeHttpServer(
11211125
Map(
1122-
"/blob.jwt" -> (HttpStatus.TOO_MANY_REQUESTS_429, newBlobJwt
1123-
.getBytes(StandardCharsets.UTF_8))
1126+
"/blob.jwt" -> (_ =>
1127+
(
1128+
HttpStatus.TOO_MANY_REQUESTS_429,
1129+
newBlobJwt
1130+
.getBytes(StandardCharsets.UTF_8),
1131+
)
1132+
)
11241133
)
11251134
)
11261135
startServer(server)
@@ -1181,10 +1190,17 @@ class FidoMetadataDownloaderSpec
11811190
val (server, _, httpsCert) =
11821191
makeHttpServer(
11831192
Map(
1184-
"/chain.pem" -> (200, certChainPem.getBytes(
1185-
StandardCharsets.UTF_8
1186-
)),
1187-
"/blob.jwt" -> (200, blobJwt.getBytes(StandardCharsets.UTF_8)),
1193+
"/chain.pem" -> (_ =>
1194+
(
1195+
200,
1196+
certChainPem.getBytes(
1197+
StandardCharsets.UTF_8
1198+
),
1199+
)
1200+
),
1201+
"/blob.jwt" -> (_ =>
1202+
(200, blobJwt.getBytes(StandardCharsets.UTF_8))
1203+
),
11881204
)
11891205
)
11901206
startServer(server)
@@ -2289,6 +2305,123 @@ class FidoMetadataDownloaderSpec
22892305
blob should not be null
22902306
blob.getNo should equal(newBlobNo)
22912307
}
2308+
2309+
withEachLoadMethod(load => {
2310+
describe(
2311+
"""The download request sends the If-None-Match header set to the "no" value of the cached BLOB"""
2312+
) {
2313+
val oldBlobExpires = CertValidFrom.plus(10, ChronoUnit.DAYS)
2314+
val newBlobExpires = oldBlobExpires.plus(5, ChronoUnit.DAYS)
2315+
2316+
val oldBlobJwt =
2317+
makeBlob(
2318+
List(blobCert),
2319+
blobKeypair,
2320+
oldBlobExpires.atOffset(ZoneOffset.UTC).toLocalDate,
2321+
no = oldBlobNo,
2322+
)
2323+
val newBlobJwt =
2324+
makeBlob(
2325+
List(blobCert),
2326+
blobKeypair,
2327+
newBlobExpires.atOffset(ZoneOffset.UTC).toLocalDate,
2328+
no = newBlobNo,
2329+
)
2330+
2331+
def downloader(
2332+
currentTime: Instant,
2333+
serverUrl: String,
2334+
serverCert: X509Certificate,
2335+
): FidoMetadataDownloader = {
2336+
FidoMetadataDownloader
2337+
.builder()
2338+
.expectLegalHeader(
2339+
"Kom ihåg att du aldrig får snyta dig i mattan!"
2340+
)
2341+
.useTrustRoot(trustRootCert)
2342+
.downloadBlob(new URL(s"${serverUrl}/blob.jwt"))
2343+
.useBlobCache(
2344+
() =>
2345+
Optional.of(
2346+
new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8))
2347+
),
2348+
_ => {},
2349+
)
2350+
.clock(Clock.fixed(currentTime, ZoneOffset.UTC))
2351+
.useCrls(crls.asJava)
2352+
.trustHttpsCerts(serverCert)
2353+
.build()
2354+
}
2355+
2356+
/** Needed to work around some strange scalafmt issues */
2357+
def toQuoted(i: Int): String = String.join("", "\"", i.toString, "\"")
2358+
2359+
it("when the cached BLOB is up to date.") {
2360+
var requestRan = false
2361+
var requestSucceeded = false
2362+
val (server, serverUrl, serverCert) =
2363+
makeHttpServer(
2364+
"/blob.jwt",
2365+
newBlobJwt,
2366+
request => {
2367+
requestRan = true
2368+
request.getHeader("If-None-Match") should equal(
2369+
toQuoted(oldBlobNo)
2370+
)
2371+
// The above assertion won't directly cause the test to fail, since it runs in the server context.
2372+
requestSucceeded = true
2373+
},
2374+
)
2375+
startServer(server)
2376+
2377+
val blob =
2378+
load(
2379+
downloader(
2380+
oldBlobExpires.minus(1, ChronoUnit.DAYS),
2381+
serverUrl,
2382+
serverCert,
2383+
)
2384+
).getPayload
2385+
blob should not be null
2386+
if (requestRan) {
2387+
blob.getNo should equal(newBlobNo)
2388+
requestSucceeded should be(true)
2389+
} else {
2390+
blob.getNo should equal(oldBlobNo)
2391+
}
2392+
}
2393+
2394+
it("when the cached BLOB is expired.") {
2395+
var requestSucceeded = false
2396+
val (server, serverUrl, serverCert) =
2397+
makeHttpServer(
2398+
"/blob.jwt",
2399+
newBlobJwt,
2400+
request => {
2401+
request.getHeader("If-None-Match") should equal(
2402+
toQuoted(oldBlobNo)
2403+
)
2404+
// The above assertion won't directly cause the test to fail, since it runs in the server context.
2405+
requestSucceeded = true
2406+
},
2407+
)
2408+
startServer(server)
2409+
2410+
val blob =
2411+
load(
2412+
downloader(
2413+
oldBlobExpires.plus(1, ChronoUnit.DAYS),
2414+
serverUrl,
2415+
serverCert,
2416+
)
2417+
).getPayload
2418+
blob should not be null
2419+
blob.getNo should equal(newBlobNo)
2420+
requestSucceeded should be(true)
2421+
}
2422+
2423+
}
2424+
})
22922425
}
22932426

22942427
}

0 commit comments

Comments
 (0)