Skip to content

Commit ada34a4

Browse files
committed
Add cachePolicy option to dynamically opt out of MDS cache fallback
1 parent a218c2f commit ada34a4

3 files changed

Lines changed: 184 additions & 46 deletions

File tree

NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ New features:
5959
status, but returns an `ETag` response header matching the `"no"` of the
6060
cached BLOB, if any, this is now interpreted like a successful `304 Not
6161
Modified` response.
62+
* Added `.cachePolicy` setting to `FidoMetadataDownloader` to allow dynamically
63+
opting out of falling back to cache when a BLOB download fails.
6264

6365
Fixes:
6466

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
import java.util.Set;
7979
import java.util.UUID;
8080
import java.util.function.Consumer;
81+
import java.util.function.Function;
8182
import java.util.function.Supplier;
8283
import java.util.stream.Collectors;
8384
import java.util.stream.Stream;
@@ -122,6 +123,7 @@ public final class FidoMetadataDownloader {
122123
@NonNull private final Clock clock;
123124
private final KeyStore httpsTrustStore;
124125
private final boolean verifyDownloadsOnly;
126+
private final Function<Exception, CachePolicyDecision> cachePolicy;
125127

126128
/** For overriding JSON mapper settings in tests. */
127129
private final Supplier<ObjectMapper> makeHeaderJsonMapper;
@@ -158,6 +160,8 @@ public static class FidoMetadataDownloaderBuilder {
158160
@NonNull private Clock clock = Clock.systemUTC();
159161
private KeyStore httpsTrustStore = null;
160162
private boolean verifyDownloadsOnly = false;
163+
private Function<Exception, CachePolicyDecision> cachePolicy =
164+
(e) -> CachePolicyDecision.USE_CACHED;
161165

162166
private Supplier<ObjectMapper> makeHeaderJsonMapper =
163167
FidoMetadataDownloader::defaultHeaderJsonMapper;
@@ -182,6 +186,7 @@ public FidoMetadataDownloader build() {
182186
clock,
183187
httpsTrustStore,
184188
verifyDownloadsOnly,
189+
cachePolicy,
185190
makeHeaderJsonMapper,
186191
makePayloadJsonMapper);
187192
}
@@ -657,6 +662,40 @@ public FidoMetadataDownloaderBuilder verifyDownloadsOnly(final boolean verifyDow
657662
return this;
658663
}
659664

665+
/**
666+
* Define a policy for how {@link #refreshBlob()} and {@link #loadCachedBlob()} should behave
667+
* when a BLOB download fails.
668+
*
669+
* <p><code>cachePolicy</code> will be invoked when a cached BLOB is available and any attempt
670+
* to download, parse and verify a new BLOB fails. Its argument will be the {@link Exception}
671+
* that caused the failure. If <code>cachePolicy</code> returns {@link
672+
* CachePolicyDecision#USE_CACHED}, then the {@link #refreshBlob()} or {@link #loadCachedBlob()}
673+
* invocation will log a warning and return the cached BLOB as a successful result. If <code>
674+
* cachePolicy</code> returns {@link CachePolicyDecision#THROW}, then the exception will be
675+
* re-thrown and the {@link #refreshBlob()} or {@link #loadCachedBlob()} invocation will fail.
676+
*
677+
* <p><code>cachePolicy</code> MUST NOT return <code>null</code>.
678+
*
679+
* <p>When no cached BLOB is available, the exception is automatically re-thrown and <code>
680+
* cachePolicy</code> is not invoked.
681+
*
682+
* <p>See the documentation of {@link #refreshBlob()} and {@link #loadCachedBlob()} for what
683+
* kinds of exceptions may be thrown.
684+
*
685+
* <p>The default policy always returns {@link CachePolicyDecision#USE_CACHED}.
686+
*
687+
* @param cachePolicy the policy used to decide whether to throw or fall back to cache when a
688+
* BLOB download fails. MUST NOT return <code>null</code>.
689+
* @see CachePolicyDecision
690+
* @see #refreshBlob()
691+
* @see #loadCachedBlob() ()
692+
*/
693+
public FidoMetadataDownloaderBuilder cachePolicy(
694+
final Function<Exception, CachePolicyDecision> cachePolicy) {
695+
this.cachePolicy = cachePolicy;
696+
return this;
697+
}
698+
660699
/** For internal testing use only. */
661700
FidoMetadataDownloaderBuilder headerJsonMapper(
662701
final Supplier<ObjectMapper> makeHeaderJsonMapper) {
@@ -929,15 +968,25 @@ private Optional<MetadataBLOB> refreshBlobInternal(
929968
}
930969
} catch (FidoMetadataDownloaderException e) {
931970
if (e.getReason() == Reason.BAD_SIGNATURE && cached.isPresent()) {
932-
log.warn("New BLOB has bad signature - falling back to cached BLOB.");
933-
return cached;
971+
switch (cachePolicy.apply(e)) {
972+
case USE_CACHED:
973+
log.warn("New BLOB has bad signature - falling back to cached BLOB.");
974+
return cached;
975+
default:
976+
throw e;
977+
}
934978
} else {
935979
throw e;
936980
}
937981
} catch (Exception e) {
938982
if (cached.isPresent()) {
939-
log.warn("Failed to download new BLOB - falling back to cached BLOB.", e);
940-
return cached;
983+
switch (cachePolicy.apply(e)) {
984+
case USE_CACHED:
985+
log.warn("Failed to download new BLOB - falling back to cached BLOB.", e);
986+
return cached;
987+
default:
988+
throw e;
989+
}
941990
} else {
942991
throw e;
943992
}
@@ -1411,4 +1460,17 @@ boolean isOk() {
14111460
return content.isPresent();
14121461
}
14131462
}
1463+
1464+
/**
1465+
* Values for the {@link FidoMetadataDownloaderBuilder#cachePolicy(Function)} argument function to
1466+
* return to express how {@link #refreshBlob()} and {@link #loadCachedBlob()} should behave when a
1467+
* BLOB download fails.
1468+
*/
1469+
public enum CachePolicyDecision {
1470+
/** Recover by returning the cached BLOB as a successful result. */
1471+
USE_CACHED,
1472+
1473+
/** Propagate the failure by re-throwing the exception. */
1474+
THROW;
1475+
}
14141476
}

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

Lines changed: 116 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.yubico.fido.metadata
22

33
import com.fasterxml.jackson.databind.node.IntNode
44
import com.fasterxml.jackson.databind.node.ObjectNode
5+
import com.yubico.fido.metadata.FidoMetadataDownloader.CachePolicyDecision
6+
import com.yubico.fido.metadata.FidoMetadataDownloader.FidoMetadataDownloaderBuilder
57
import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason
68
import com.yubico.internal.util.BinaryUtil
79
import com.yubico.internal.util.JacksonCodecs
@@ -30,6 +32,7 @@ import org.scalatestplus.junit.JUnitRunner
3032
import java.io.File
3133
import java.io.FileInputStream
3234
import java.io.FileOutputStream
35+
import java.io.IOException
3336
import java.net.URL
3437
import java.nio.charset.StandardCharsets
3538
import java.security.DigestException
@@ -52,6 +55,7 @@ import javax.servlet.http.HttpServletResponse
5255
import scala.jdk.CollectionConverters.ListHasAsScala
5356
import scala.jdk.CollectionConverters.SeqHasAsJava
5457
import scala.jdk.CollectionConverters.SetHasAsJava
58+
import scala.util.Failure
5559
import scala.util.Success
5660
import scala.util.Try
5761

@@ -1090,7 +1094,7 @@ class FidoMetadataDownloaderSpec
10901094
blob.getNo should equal(blobNo)
10911095
}
10921096

1093-
it("The cache is used if the BLOB download fails.") {
1097+
describe("The cache is used if the BLOB download fails") {
10941098
val oldBlobNo = 1
10951099
val newBlobNo = 2
10961100

@@ -1120,22 +1124,25 @@ class FidoMetadataDownloaderSpec
11201124
)
11211125
)
11221126

1123-
val (server, serverUrl, httpsCert) =
1124-
makeHttpServer(
1125-
Map(
1126-
"/blob.jwt" -> (_ =>
1127-
(
1128-
HttpStatus.TOO_MANY_REQUESTS_429,
1129-
newBlobJwt
1130-
.getBytes(StandardCharsets.UTF_8),
1127+
def test[T](
1128+
configure: FidoMetadataDownloaderBuilder => T =
1129+
(_: FidoMetadataDownloaderBuilder) => {}
1130+
): MetadataBLOBPayload = {
1131+
val (server, serverUrl, httpsCert) =
1132+
makeHttpServer(
1133+
Map(
1134+
"/blob.jwt" -> (_ =>
1135+
(
1136+
HttpStatus.TOO_MANY_REQUESTS_429,
1137+
newBlobJwt
1138+
.getBytes(StandardCharsets.UTF_8),
1139+
)
11311140
)
11321141
)
11331142
)
1134-
)
1135-
startServer(server)
1143+
startServer(server)
11361144

1137-
val blob = load(
1138-
FidoMetadataDownloader
1145+
val builder = FidoMetadataDownloader
11391146
.builder()
11401147
.expectLegalHeader(
11411148
"Kom ihåg att du aldrig får snyta dig i mattan!"
@@ -1152,10 +1159,39 @@ class FidoMetadataDownloaderSpec
11521159
.clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC))
11531160
.useCrls(crls.asJava)
11541161
.trustHttpsCerts(httpsCert)
1155-
.build()
1156-
).getPayload
1157-
blob should not be null
1158-
blob.getNo should equal(oldBlobNo)
1162+
configure(builder)
1163+
load(builder.build()).getPayload
1164+
}
1165+
1166+
it("with the default settings.") {
1167+
val blob = test()
1168+
blob should not be null
1169+
blob.getNo should equal(oldBlobNo)
1170+
}
1171+
1172+
it("and the cache policy allows it.") {
1173+
val blob = test(builder => {
1174+
builder.cachePolicy(_ => CachePolicyDecision.USE_CACHED)
1175+
})
1176+
blob should not be null
1177+
blob.getNo should equal(oldBlobNo)
1178+
}
1179+
1180+
it("unless the cache policy decides to re-throw.") {
1181+
val blob = Try(test(builder => {
1182+
builder.cachePolicy(_ => CachePolicyDecision.THROW)
1183+
}))
1184+
blob shouldBe a[Failure[_]]
1185+
blob.failed.get shouldBe an[IOException]
1186+
}
1187+
1188+
it("unless the cache policy returns null.") {
1189+
val blob = Try(test(builder => {
1190+
builder.cachePolicy(_ => null)
1191+
}))
1192+
blob shouldBe a[Failure[_]]
1193+
blob.failed.get shouldBe a[NullPointerException]
1194+
}
11591195
}
11601196
}
11611197

@@ -1806,7 +1842,7 @@ class FidoMetadataDownloaderSpec
18061842
blob.getNo should equal(oldBlobNo)
18071843
}
18081844

1809-
it("A newly downloaded BLOB is disregarded if it has an invalid signature but the cached one has a valid signature.") {
1845+
describe("A newly downloaded BLOB is disregarded if it has an invalid signature but the cached one has a valid signature") {
18101846
val oldBlobNo = 1
18111847
val newBlobNo = 2
18121848

@@ -1858,32 +1894,70 @@ class FidoMetadataDownloaderSpec
18581894
)
18591895
.mkString(".")
18601896

1861-
val (server, serverUrl, httpsCert) =
1862-
makeHttpServer("/blob.jwt", badNewBlobJwt)
1863-
startServer(server)
1897+
def test[T](
1898+
configure: FidoMetadataDownloaderBuilder => T =
1899+
(_: FidoMetadataDownloaderBuilder) => {}
1900+
): MetadataBLOBPayload = {
1901+
val (server, serverUrl, httpsCert) =
1902+
makeHttpServer("/blob.jwt", badNewBlobJwt)
1903+
startServer(server)
18641904

1865-
val blob = load(
1866-
FidoMetadataDownloader
1867-
.builder()
1868-
.expectLegalHeader(
1869-
"Kom ihåg att du aldrig får snyta dig i mattan!"
1870-
)
1871-
.useTrustRoot(trustRootCert)
1872-
.downloadBlob(new URL(s"${serverUrl}/blob.jwt"))
1873-
.useBlobCache(
1874-
() =>
1875-
Optional.of(
1876-
new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8))
1877-
),
1878-
_ => {},
1905+
val builder =
1906+
FidoMetadataDownloader
1907+
.builder()
1908+
.expectLegalHeader(
1909+
"Kom ihåg att du aldrig får snyta dig i mattan!"
1910+
)
1911+
.useTrustRoot(trustRootCert)
1912+
.downloadBlob(new URL(s"${serverUrl}/blob.jwt"))
1913+
.useBlobCache(
1914+
() =>
1915+
Optional.of(
1916+
new ByteArray(oldBlobJwt.getBytes(StandardCharsets.UTF_8))
1917+
),
1918+
_ => {},
1919+
)
1920+
.clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC))
1921+
.useCrls(crls.asJava)
1922+
.trustHttpsCerts(httpsCert)
1923+
configure(builder)
1924+
load(builder.build()).getPayload
1925+
}
1926+
1927+
it("under the default settings.") {
1928+
val blob = test()
1929+
blob should not be null
1930+
blob.getNo should equal(oldBlobNo)
1931+
}
1932+
1933+
it("if the cache policy allows falling back to the cache.") {
1934+
val blob = test(builder => {
1935+
builder.cachePolicy((_: Exception) =>
1936+
CachePolicyDecision.USE_CACHED
18791937
)
1880-
.clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC))
1881-
.useCrls(crls.asJava)
1882-
.trustHttpsCerts(httpsCert)
1883-
.build()
1884-
).getPayload
1885-
blob should not be null
1886-
blob.getNo should equal(oldBlobNo)
1938+
})
1939+
blob should not be null
1940+
blob.getNo should equal(oldBlobNo)
1941+
}
1942+
1943+
it("unless the cache policy decides to re-throw.") {
1944+
val blob = Try(test(builder => {
1945+
builder.cachePolicy((_: Exception) => CachePolicyDecision.THROW)
1946+
}))
1947+
blob shouldBe a[Failure[_]]
1948+
blob.failed.get shouldBe a[FidoMetadataDownloaderException]
1949+
blob.failed.get
1950+
.asInstanceOf[FidoMetadataDownloaderException]
1951+
.getReason should be(Reason.BAD_SIGNATURE)
1952+
}
1953+
1954+
it("unless the cache policy returns null.") {
1955+
val blob = Try(test(builder => {
1956+
builder.cachePolicy((_: Exception) => null)
1957+
}))
1958+
blob shouldBe a[Failure[_]]
1959+
blob.failed.get shouldBe a[NullPointerException]
1960+
}
18871961
}
18881962

18891963
it("If verifyDownloadsOnly is not set, a cached BLOB may expire.") {

0 commit comments

Comments
 (0)