/* * TokenSign.cpp * * This source file is part of the FoundationDB open source project * * Copyright 2013-2022 Apple Inc. and the FoundationDB project authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #include "fdbrpc/TokenSign.h" #include "flow/network.h" #include "flow/serialize.h" #include "flow/Arena.h" #include "flow/Error.h" #include "flow/IRandom.h" #include "flow/MkCert.h" #include "flow/Platform.h" #include "flow/ScopeExit.h" #include "flow/Trace.h" #include "flow/UnitTest.h" #include <fmt/format.h> #include <iterator> #include <string_view> #include <type_traits> #include <utility> #if defined(HAVE_WOLFSSL) #include <wolfssl/options.h> #endif #include <openssl/ec.h> #include <openssl/err.h> #include <openssl/evp.h> #include <openssl/x509.h> #include <rapidjson/document.h> #include <rapidjson/writer.h> #include <rapidjson/stringbuffer.h> #include <rapidjson/error/en.h> #include "fdbrpc/Base64UrlEncode.h" #include "fdbrpc/Base64UrlDecode.h" namespace { // test-only constants for generating random tenant/key names constexpr int MaxIssuerNameLenPlus1 = 25; constexpr int MaxTenantNameLenPlus1 = 17; constexpr int MaxKeyNameLenPlus1 = 21; StringRef genRandomAlphanumStringRef(Arena& arena, IRandom& rng, int maxLenPlusOne) { const auto len = rng.randomInt(1, maxLenPlusOne); auto strRaw = new (arena) uint8_t[len]; for (auto i = 0; i < len; i++) strRaw[i] = (uint8_t)rng.randomAlphaNumeric(); return StringRef(strRaw, len); } bool checkVerifyAlgorithm(PKeyAlgorithm algo, PublicKey key) { if (algo != key.algorithm()) { TraceEvent(SevWarnAlways, "TokenVerifyAlgoMismatch") .suppressFor(10) .detail("Expected", pkeyAlgorithmName(algo)) .detail("PublicKeyAlgorithm", key.algorithmName()); return false; } else { return true; } } bool checkSignAlgorithm(PKeyAlgorithm algo, PrivateKey key) { if (algo != key.algorithm()) { TraceEvent(SevWarnAlways, "TokenSignAlgoMismatch") .suppressFor(10) .detail("Expected", pkeyAlgorithmName(algo)) .detail("PublicKeyAlgorithm", key.algorithmName()); return false; } else { return true; } } } // namespace namespace authz { using MessageDigestMethod = const EVP_MD*; Algorithm algorithmFromString(StringRef s) noexcept { if (s == "RS256"_sr) return Algorithm::RS256; else if (s == "ES256"_sr) return Algorithm::ES256; else return Algorithm::UNKNOWN; } std::pair<PKeyAlgorithm, MessageDigestMethod> getMethod(Algorithm alg) { if (alg == Algorithm::RS256) { return { PKeyAlgorithm::RSA, ::EVP_sha256() }; } else if (alg == Algorithm::ES256) { return { PKeyAlgorithm::EC, ::EVP_sha256() }; } else { return { PKeyAlgorithm::UNSUPPORTED, nullptr }; } } std::string_view getAlgorithmName(Algorithm alg) { if (alg == Algorithm::RS256) return { "RS256" }; else if (alg == Algorithm::ES256) return { "ES256" }; else UNREACHABLE(); } } // namespace authz namespace authz::flatbuffers { SignedTokenRef signToken(Arena& arena, TokenRef token, StringRef keyName, PrivateKey privateKey) { auto ret = SignedTokenRef{}; auto writer = ObjectWriter([&arena](size_t len) { return new (arena) uint8_t[len]; }, IncludeVersion()); writer.serialize(token); auto tokenStr = writer.toStringRef(); auto [signAlgo, digest] = getMethod(Algorithm::ES256); if (!checkSignAlgorithm(signAlgo, privateKey)) { throw digital_signature_ops_error(); } auto sig = privateKey.sign(arena, tokenStr, *digest); ret.token = tokenStr; ret.signature = sig; ret.keyName = StringRef(arena, keyName); return ret; } bool verifyToken(SignedTokenRef signedToken, PublicKey publicKey) { auto [keyAlg, digest] = getMethod(Algorithm::ES256); if (!checkVerifyAlgorithm(keyAlg, publicKey)) return false; return publicKey.verify(signedToken.token, signedToken.signature, *digest); } TokenRef makeRandomTokenSpec(Arena& arena, IRandom& rng) { auto token = TokenRef{}; token.expiresAt = timer_monotonic() * (0.5 + rng.random01()); const auto numTenants = rng.randomInt(1, 3); for (auto i = 0; i < numTenants; i++) { token.tenants.push_back(arena, genRandomAlphanumStringRef(arena, rng, MaxTenantNameLenPlus1)); } return token; } } // namespace authz::flatbuffers namespace authz::jwt { template <class FieldType, size_t NameLen> void appendField(fmt::memory_buffer& b, char const (&name)[NameLen], Optional<FieldType> const& field) { if (!field.present()) return; auto const& f = field.get(); auto bi = std::back_inserter(b); if constexpr (std::is_same_v<FieldType, VectorRef<StringRef>>) { fmt::format_to(bi, " {}=[", name); for (auto i = 0; i < f.size(); i++) { if (i) fmt::format_to(bi, ","); fmt::format_to(bi, f[i].toStringView()); } fmt::format_to(bi, "]"); } else if constexpr (std::is_same_v<FieldType, StringRef>) { fmt::format_to(bi, " {}={}", name, f.toStringView()); } else { fmt::format_to(bi, " {}={}", name, f); } } StringRef TokenRef::toStringRef(Arena& arena) { auto buf = fmt::memory_buffer(); fmt::format_to(std::back_inserter(buf), "alg={} kid={}", getAlgorithmName(algorithm), keyId.toStringView()); appendField(buf, "iss", issuer); appendField(buf, "sub", subject); appendField(buf, "aud", audience); appendField(buf, "iat", issuedAtUnixTime); appendField(buf, "exp", expiresAtUnixTime); appendField(buf, "nbf", notBeforeUnixTime); appendField(buf, "jti", tokenId); appendField(buf, "tenants", tenants); auto str = new (arena) uint8_t[buf.size()]; memcpy(str, buf.data(), buf.size()); return StringRef(str, buf.size()); } template <class FieldType, class Writer> void putField(Optional<FieldType> const& field, Writer& wr, const char* fieldName) { if (!field.present()) return; wr.Key(fieldName); auto const& value = field.get(); static_assert(std::is_same_v<StringRef, FieldType> || std::is_same_v<FieldType, uint64_t> || std::is_same_v<FieldType, VectorRef<StringRef>>); if constexpr (std::is_same_v<StringRef, FieldType>) { wr.String(reinterpret_cast<const char*>(value.begin()), value.size()); } else if constexpr (std::is_same_v<FieldType, uint64_t>) { wr.Uint64(value); } else { wr.StartArray(); for (auto elem : value) { wr.String(reinterpret_cast<const char*>(elem.begin()), elem.size()); } wr.EndArray(); } } StringRef makeTokenPart(Arena& arena, TokenRef tokenSpec) { using Buffer = rapidjson::StringBuffer; using Writer = rapidjson::Writer<Buffer>; auto headerBuffer = Buffer(); auto payloadBuffer = Buffer(); auto header = Writer(headerBuffer); auto payload = Writer(payloadBuffer); header.StartObject(); header.Key("typ"); header.String("JWT"); auto algo = getAlgorithmName(tokenSpec.algorithm); header.Key("alg"); header.String(algo.data(), algo.size()); auto kid = tokenSpec.keyId.toStringView(); header.Key("kid"); header.String(kid.data(), kid.size()); header.EndObject(); payload.StartObject(); putField(tokenSpec.issuer, payload, "iss"); putField(tokenSpec.subject, payload, "sub"); putField(tokenSpec.audience, payload, "aud"); putField(tokenSpec.issuedAtUnixTime, payload, "iat"); putField(tokenSpec.expiresAtUnixTime, payload, "exp"); putField(tokenSpec.notBeforeUnixTime, payload, "nbf"); putField(tokenSpec.tokenId, payload, "jti"); putField(tokenSpec.tenants, payload, "tenants"); payload.EndObject(); auto const headerPartLen = base64url::encodedLength(headerBuffer.GetSize()); auto const payloadPartLen = base64url::encodedLength(payloadBuffer.GetSize()); auto const totalLen = headerPartLen + 1 + payloadPartLen; auto out = new (arena) uint8_t[totalLen]; auto cur = out; cur += base64url::encode(reinterpret_cast<const uint8_t*>(headerBuffer.GetString()), headerBuffer.GetSize(), cur); ASSERT_EQ(cur - out, headerPartLen); *cur++ = '.'; cur += base64url::encode(reinterpret_cast<const uint8_t*>(payloadBuffer.GetString()), payloadBuffer.GetSize(), cur); ASSERT_EQ(cur - out, totalLen); return StringRef(out, totalLen); } StringRef signToken(Arena& arena, TokenRef tokenSpec, PrivateKey privateKey) { auto tmpArena = Arena(); auto tokenPart = makeTokenPart(tmpArena, tokenSpec); auto [signAlgo, digest] = getMethod(tokenSpec.algorithm); if (!checkSignAlgorithm(signAlgo, privateKey)) { throw digital_signature_ops_error(); } auto plainSig = privateKey.sign(tmpArena, tokenPart, *digest); auto const sigPartLen = base64url::encodedLength(plainSig.size()); auto const totalLen = tokenPart.size() + 1 + sigPartLen; auto out = new (arena) uint8_t[totalLen]; auto cur = out; ::memcpy(cur, tokenPart.begin(), tokenPart.size()); cur += tokenPart.size(); *cur++ = '.'; cur += base64url::encode(plainSig.begin(), plainSig.size(), cur); ASSERT_EQ(cur - out, totalLen); return StringRef(out, totalLen); } bool parseHeaderPart(Arena& arena, TokenRef& token, StringRef b64urlHeader) { auto tmpArena = Arena(); auto optHeader = base64url::decode(tmpArena, b64urlHeader); if (!optHeader.present()) return false; auto header = optHeader.get(); auto d = rapidjson::Document(); d.Parse(reinterpret_cast<const char*>(header.begin()), header.size()); if (d.HasParseError()) { TraceEvent(SevWarnAlways, "TokenHeaderJsonParseError") .suppressFor(10) .detail("Header", header.toString()) .detail("Message", GetParseError_En(d.GetParseError())) .detail("Offset", d.GetErrorOffset()); return false; } if (!d.IsObject()) return false; auto typItr = d.FindMember("typ"); if (typItr == d.MemberEnd() || !typItr->value.IsString()) return false; auto algItr = d.FindMember("alg"); if (algItr == d.MemberEnd() || !algItr->value.IsString()) return false; auto kidItr = d.FindMember("kid"); if (kidItr == d.MemberEnd() || !kidItr->value.IsString()) return false; auto const& typ = typItr->value; auto const& alg = algItr->value; auto const& kid = kidItr->value; auto typValue = StringRef(reinterpret_cast<const uint8_t*>(typ.GetString()), typ.GetStringLength()); if (typValue != "JWT"_sr) return false; auto algValue = StringRef(reinterpret_cast<const uint8_t*>(alg.GetString()), alg.GetStringLength()); auto algType = algorithmFromString(algValue); if (algType == Algorithm::UNKNOWN) return false; token.algorithm = algType; token.keyId = StringRef(arena, reinterpret_cast<const uint8_t*>(kid.GetString()), kid.GetStringLength()); return true; } template <class FieldType> bool parseField(Arena& arena, Optional<FieldType>& out, const rapidjson::Document& d, const char* fieldName) { auto fieldItr = d.FindMember(fieldName); if (fieldItr == d.MemberEnd()) return true; auto const& field = fieldItr->value; static_assert(std::is_same_v<StringRef, FieldType> || std::is_same_v<FieldType, uint64_t> || std::is_same_v<FieldType, VectorRef<StringRef>>); if constexpr (std::is_same_v<FieldType, StringRef>) { if (!field.IsString()) return false; out = StringRef(arena, reinterpret_cast<const uint8_t*>(field.GetString()), field.GetStringLength()); } else if constexpr (std::is_same_v<FieldType, uint64_t>) { if (!field.IsUint64()) return false; out = field.GetUint64(); } else { if (!field.IsArray()) return false; if (field.Size() > 0) { auto vector = new (arena) StringRef[field.Size()]; for (auto i = 0; i < field.Size(); i++) { if (!field[i].IsString()) return false; vector[i] = StringRef( arena, reinterpret_cast<const uint8_t*>(field[i].GetString()), field[i].GetStringLength()); } out = VectorRef<StringRef>(vector, field.Size()); } else { out = VectorRef<StringRef>(); } } return true; } bool parsePayloadPart(Arena& arena, TokenRef& token, StringRef b64urlPayload) { auto tmpArena = Arena(); auto optPayload = base64url::decode(tmpArena, b64urlPayload); if (!optPayload.present()) return false; auto payload = optPayload.get(); auto d = rapidjson::Document(); d.Parse(reinterpret_cast<const char*>(payload.begin()), payload.size()); if (d.HasParseError()) { TraceEvent(SevWarnAlways, "TokenPayloadJsonParseError") .suppressFor(10) .detail("Payload", payload.toString()) .detail("Message", GetParseError_En(d.GetParseError())) .detail("Offset", d.GetErrorOffset()); return false; } if (!d.IsObject()) return false; if (!parseField(arena, token.issuer, d, "iss")) return false; if (!parseField(arena, token.subject, d, "sub")) return false; if (!parseField(arena, token.audience, d, "aud")) return false; if (!parseField(arena, token.tokenId, d, "jti")) return false; if (!parseField(arena, token.issuedAtUnixTime, d, "iat")) return false; if (!parseField(arena, token.expiresAtUnixTime, d, "exp")) return false; if (!parseField(arena, token.notBeforeUnixTime, d, "nbf")) return false; if (!parseField(arena, token.tenants, d, "tenants")) return false; return true; } bool parseSignaturePart(Arena& arena, TokenRef& token, StringRef b64urlSignature) { auto optSig = base64url::decode(arena, b64urlSignature); if (!optSig.present()) return false; token.signature = optSig.get(); return true; } StringRef signaturePart(StringRef token) { token.eat("."_sr); token.eat("."_sr); return token; } bool parseToken(Arena& arena, TokenRef& token, StringRef signedToken) { auto b64urlHeader = signedToken.eat("."_sr); auto b64urlPayload = signedToken.eat("."_sr); auto b64urlSignature = signedToken; if (b64urlHeader.empty() || b64urlPayload.empty() || b64urlSignature.empty()) return false; if (!parseHeaderPart(arena, token, b64urlHeader)) return false; if (!parsePayloadPart(arena, token, b64urlPayload)) return false; if (!parseSignaturePart(arena, token, b64urlSignature)) return false; return true; } bool verifyToken(StringRef signedToken, PublicKey publicKey) { auto arena = Arena(); auto fullToken = signedToken; auto b64urlHeader = signedToken.eat("."_sr); auto b64urlPayload = signedToken.eat("."_sr); auto b64urlSignature = signedToken; if (b64urlHeader.empty() || b64urlPayload.empty() || b64urlSignature.empty()) return false; auto b64urlTokenPart = fullToken.substr(0, b64urlHeader.size() + 1 + b64urlPayload.size()); auto optSig = base64url::decode(arena, b64urlSignature); if (!optSig.present()) return false; auto sig = optSig.get(); auto parsedToken = TokenRef(); if (!parseHeaderPart(arena, parsedToken, b64urlHeader)) return false; auto [verifyAlgo, digest] = getMethod(parsedToken.algorithm); if (!checkVerifyAlgorithm(verifyAlgo, publicKey)) return false; return publicKey.verify(b64urlTokenPart, sig, *digest); } TokenRef makeRandomTokenSpec(Arena& arena, IRandom& rng, Algorithm alg) { if (alg != Algorithm::ES256) { throw unsupported_operation(); } auto ret = TokenRef{}; ret.algorithm = alg; ret.keyId = genRandomAlphanumStringRef(arena, rng, MaxKeyNameLenPlus1); ret.issuer = genRandomAlphanumStringRef(arena, rng, MaxIssuerNameLenPlus1); ret.subject = genRandomAlphanumStringRef(arena, rng, MaxIssuerNameLenPlus1); ret.tokenId = genRandomAlphanumStringRef(arena, rng, 31); auto numAudience = rng.randomInt(1, 5); auto aud = new (arena) StringRef[numAudience]; for (auto i = 0; i < numAudience; i++) aud[i] = genRandomAlphanumStringRef(arena, rng, MaxTenantNameLenPlus1); ret.audience = VectorRef<StringRef>(aud, numAudience); ret.issuedAtUnixTime = uint64_t(std::floor(g_network->timer())); ret.notBeforeUnixTime = ret.issuedAtUnixTime.get(); ret.expiresAtUnixTime = ret.issuedAtUnixTime.get() + rng.randomInt(360, 1080 + 1); auto numTenants = rng.randomInt(1, 3); auto tenants = new (arena) StringRef[numTenants]; for (auto i = 0; i < numTenants; i++) tenants[i] = genRandomAlphanumStringRef(arena, rng, MaxTenantNameLenPlus1); ret.tenants = VectorRef<StringRef>(tenants, numTenants); return ret; } } // namespace authz::jwt void forceLinkTokenSignTests() {} TEST_CASE("/fdbrpc/TokenSign/FlatBuffer") { const auto numIters = 100; for (auto i = 0; i < numIters; i++) { auto arena = Arena(); auto privateKey = mkcert::makeEcP256(); auto& rng = *deterministicRandom(); auto tokenSpec = authz::flatbuffers::makeRandomTokenSpec(arena, rng); auto keyName = genRandomAlphanumStringRef(arena, rng, MaxKeyNameLenPlus1); auto signedToken = authz::flatbuffers::signToken(arena, tokenSpec, keyName, privateKey); const auto verifyExpectOk = authz::flatbuffers::verifyToken(signedToken, privateKey.toPublic()); ASSERT(verifyExpectOk); // try tampering with signed token by adding one more tenant tokenSpec.tenants.push_back(arena, genRandomAlphanumStringRef(arena, rng, MaxTenantNameLenPlus1)); auto writer = ObjectWriter([&arena](size_t len) { return new (arena) uint8_t[len]; }, IncludeVersion()); writer.serialize(tokenSpec); signedToken.token = writer.toStringRef(); const auto verifyExpectFail = authz::flatbuffers::verifyToken(signedToken, privateKey.toPublic()); ASSERT(!verifyExpectFail); } printf("%d runs OK\n", numIters); return Void(); } TEST_CASE("/fdbrpc/TokenSign/JWT") { const auto numIters = 100; for (auto i = 0; i < numIters; i++) { auto arena = Arena(); auto privateKey = mkcert::makeEcP256(); auto& rng = *deterministicRandom(); auto tokenSpec = authz::jwt::makeRandomTokenSpec(arena, rng, authz::Algorithm::ES256); auto signedToken = authz::jwt::signToken(arena, tokenSpec, privateKey); const auto verifyExpectOk = authz::jwt::verifyToken(signedToken, privateKey.toPublic()); ASSERT(verifyExpectOk); auto signaturePart = signedToken; signaturePart.eat("."_sr); signaturePart.eat("."_sr); { auto parsedToken = authz::jwt::TokenRef{}; auto tmpArena = Arena(); auto parseOk = parseToken(tmpArena, parsedToken, signedToken); ASSERT(parseOk); ASSERT_EQ(tokenSpec.algorithm, parsedToken.algorithm); ASSERT(tokenSpec.issuer == parsedToken.issuer); ASSERT(tokenSpec.subject == parsedToken.subject); ASSERT(tokenSpec.tokenId == parsedToken.tokenId); ASSERT(tokenSpec.audience == parsedToken.audience); ASSERT(tokenSpec.keyId == parsedToken.keyId); ASSERT_EQ(tokenSpec.issuedAtUnixTime.get(), parsedToken.issuedAtUnixTime.get()); ASSERT_EQ(tokenSpec.expiresAtUnixTime.get(), parsedToken.expiresAtUnixTime.get()); ASSERT_EQ(tokenSpec.notBeforeUnixTime.get(), parsedToken.notBeforeUnixTime.get()); ASSERT(tokenSpec.tenants == parsedToken.tenants); auto optSig = base64url::decode(tmpArena, signaturePart); ASSERT(optSig.present()); ASSERT(optSig.get() == parsedToken.signature); } // try tampering with signed token by adding one more tenant tokenSpec.tenants.get().push_back(arena, genRandomAlphanumStringRef(arena, rng, MaxTenantNameLenPlus1)); auto tamperedTokenPart = makeTokenPart(arena, tokenSpec); auto tamperedTokenString = fmt::format("{}.{}", tamperedTokenPart.toString(), signaturePart.toString()); const auto verifyExpectFail = authz::jwt::verifyToken(StringRef(tamperedTokenString), privateKey.toPublic()); ASSERT(!verifyExpectFail); } printf("%d runs OK\n", numIters); return Void(); } TEST_CASE("/fdbrpc/TokenSign/JWT/ToStringRef") { auto t = authz::jwt::TokenRef(); t.algorithm = authz::Algorithm::ES256; t.issuer = "issuer"_sr; t.subject = "subject"_sr; StringRef aud[3]{ "aud1"_sr, "aud2"_sr, "aud3"_sr }; t.audience = VectorRef<StringRef>(aud, 3); t.issuedAtUnixTime = 123ul; t.expiresAtUnixTime = 456ul; t.notBeforeUnixTime = 789ul; t.keyId = "keyId"_sr; t.tokenId = "tokenId"_sr; StringRef tenants[2]{ "tenant1"_sr, "tenant2"_sr }; t.tenants = VectorRef<StringRef>(tenants, 2); auto arena = Arena(); auto tokenStr = t.toStringRef(arena); auto tokenStrExpected = "alg=ES256 kid=keyId iss=issuer sub=subject aud=[aud1,aud2,aud3] iat=123 exp=456 nbf=789 jti=tokenId tenants=[tenant1,tenant2]"_sr; if (tokenStr != tokenStrExpected) { fmt::print("Expected: {}\nGot : {}\n", tokenStrExpected.toStringView(), tokenStr.toStringView()); ASSERT(false); } else { fmt::print("TEST OK\n"); } return Void(); } TEST_CASE("/fdbrpc/TokenSign/bench") { constexpr auto repeat = 5; constexpr auto numSamples = 10000; auto keys = std::vector<PrivateKey>(numSamples); auto pubKeys = std::vector<PublicKey>(numSamples); for (auto i = 0; i < numSamples; i++) { keys[i] = mkcert::makeEcP256(); pubKeys[i] = keys[i].toPublic(); } fmt::print("{} keys generated\n", numSamples); auto& rng = *deterministicRandom(); auto arena = Arena(); auto jwts = new (arena) StringRef[numSamples]; auto fbs = new (arena) StringRef[numSamples]; { auto tmpArena = Arena(); for (auto i = 0; i < numSamples; i++) { auto jwtSpec = authz::jwt::makeRandomTokenSpec(tmpArena, rng, authz::Algorithm::ES256); jwts[i] = authz::jwt::signToken(arena, jwtSpec, keys[i]); auto fbSpec = authz::flatbuffers::makeRandomTokenSpec(tmpArena, rng); auto fbToken = authz::flatbuffers::signToken(tmpArena, fbSpec, "defaultKey"_sr, keys[i]); auto wr = ObjectWriter([&arena](size_t len) { return new (arena) uint8_t[len]; }, Unversioned()); wr.serialize(fbToken); fbs[i] = wr.toStringRef(); } } fmt::print("{} FB/JWT tokens generated\n", numSamples); auto jwtBegin = timer_monotonic(); for (auto rep = 0; rep < repeat; rep++) { for (auto i = 0; i < numSamples; i++) { auto verifyOk = authz::jwt::verifyToken(jwts[i], pubKeys[i]); ASSERT(verifyOk); } } auto jwtEnd = timer_monotonic(); fmt::print("JWT: {:.2f} OPS\n", repeat * numSamples / (jwtEnd - jwtBegin)); auto fbBegin = timer_monotonic(); for (auto rep = 0; rep < repeat; rep++) { for (auto i = 0; i < numSamples; i++) { auto signedToken = ObjectReader::fromStringRef<Standalone<authz::flatbuffers::SignedTokenRef>>(fbs[i], Unversioned()); auto verifyOk = authz::flatbuffers::verifyToken(signedToken, pubKeys[i]); ASSERT(verifyOk); } } auto fbEnd = timer_monotonic(); fmt::print("FlatBuffers: {:.2f} OPS\n", repeat * numSamples / (fbEnd - fbBegin)); return Void(); }