Skip to content

Commit 8c50ff9

Browse files
committed
Use HTTP cache headers in MDS BLOB downloads
FIDO MDS returns an `ETag` response header and supports the `If-None-Match` request header in BLOB download requests [1]: >The system may pass the serial number of the latest cached Metadata Service >BLOB to the service (GET `/?localCopySerial=77`). In that case, the MDS will >return HTTP code 304 (Not Modified) if no newer MDS blob is available. >Alternatively, the serial number of the local copy could be provided through >the "If-None-Match" header field. The server will always return the serial >number in the ETag header field. If both, the "localCopySerial" parameter and >the "If-None-Match" header are provided, the server will only process the >"localCopySerial" parameter. [1]: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1-ps-20250521.html#sctn-mds-blob-proc-rules
1 parent 0a520a0 commit 8c50ff9

2 files changed

Lines changed: 199 additions & 13 deletions

File tree

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,10 @@ 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+
cached.map(cachedBlob -> String.format("%d", cachedBlob.getPayload().getNo())));
880883
final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot);
881884
log.debug("New BLOB downloaded.");
882885

@@ -906,6 +909,11 @@ private Optional<MetadataBLOB> refreshBlobInternal(
906909
}
907910

908911
return Optional.of(downloadedBlob);
912+
913+
} catch (NotModified e) {
914+
log.debug("Remote BLOB not modified - using cached BLOB.");
915+
return cached;
916+
909917
} catch (FidoMetadataDownloaderException e) {
910918
if (e.getReason() == Reason.BAD_SIGNATURE && cached.isPresent()) {
911919
log.warn("New BLOB has bad signature - falling back to cached BLOB.");
@@ -1062,6 +1070,17 @@ private Optional<ByteArray> readCacheFile(File cacheFile) throws IOException {
10621070
}
10631071

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

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

10871138
return readAll(conn.getInputStream());
@@ -1321,4 +1372,6 @@ private Optional<CertStore> fetchCrlDistributionPoints(
13211372
CertStore.getInstance("Collection", new CollectionCertStoreParameters(crldpCrls)));
13221373
}
13231374
}
1375+
1376+
private static class NotModified extends Throwable {}
13241377
}

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)