// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/351564777): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif
#include "device/fido/mac/credential_metadata.h"
#include <ostream>
#include <string_view>
#include "base/check.h"
#include "base/notreached.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "components/cbor/reader.h"
#include "components/cbor/values.h"
#include "components/cbor/writer.h"
#include "device/fido/public_key_credential_user_entity.h"
#include "third_party/boringssl/src/include/openssl/digest.h"
#include "third_party/boringssl/src/include/openssl/hkdf.h"
#include "third_party/boringssl/src/include/openssl/rand.h"
namespace device::fido::mac {
static constexpr size_t kNonceLength = 12;
namespace {
// MakeAad returns the concatenation of |version| and |rp_id|,
// which is used as the additional authenticated data (AAD) input to the AEAD.
std::vector<uint8_t> MakeAad(CredentialMetadata::Version version,
const std::string& rp_id) {
std::vector<uint8_t> result = {static_cast<uint8_t>(version)};
result.insert(result.end(), rp_id.data(), rp_id.data() + rp_id.size());
return result;
}
// Cryptor provides methods for encrypting and authenticating credential
// metadata.
class Cryptor {
public:
explicit Cryptor(std::string secret) : secret_(std::move(secret)) {}
Cryptor(Cryptor&&) = default;
Cryptor& operator=(Cryptor&&) = default;
~Cryptor() = default;
enum Algorithm : uint8_t {
kAes256Gcm = 0,
kHmacSha256 = 1,
kAes256GcmSiv = 2,
};
std::vector<uint8_t> Seal(Algorithm alg,
base::span<const uint8_t> nonce,
base::span<const uint8_t> plaintext,
base::span<const uint8_t> authenticated_data) const;
std::optional<std::vector<uint8_t>> Unseal(
Algorithm alg,
base::span<const uint8_t> nonce,
base::span<const uint8_t> ciphertext,
base::span<const uint8_t> authenticated_data) const;
std::string HmacForStorage(std::string_view data) const;
private:
static std::optional<crypto::Aead::AeadAlgorithm> ToAeadAlgorithm(
Algorithm alg);
// Derives an Algorithm-specific key from |secret_| to avoid using the same
// key for different algorithms.
std::string DeriveKey(Algorithm alg) const;
Cryptor(const Cryptor&) = delete;
Cryptor& operator=(const Cryptor&) = delete;
// Used to derive keys for the HMAC and AEAD operations. Chrome picks
// different secrets for each user profile. This ensures that credentials are
// logically tied to the Chrome user profile under which they were created.
std::string secret_;
};
std::vector<uint8_t> Cryptor::Seal(
Algorithm algorithm,
base::span<const uint8_t> nonce,
base::span<const uint8_t> plaintext,
base::span<const uint8_t> authenticated_data) const {
const std::string key = DeriveKey(algorithm);
crypto::Aead aead(*ToAeadAlgorithm(algorithm));
aead.Init(&key);
return aead.Seal(plaintext, nonce, authenticated_data);
}
std::optional<std::vector<uint8_t>> Cryptor::Unseal(
Algorithm algorithm,
base::span<const uint8_t> nonce,
base::span<const uint8_t> ciphertext,
base::span<const uint8_t> authenticated_data) const {
const std::string key = DeriveKey(algorithm);
crypto::Aead aead(*ToAeadAlgorithm(algorithm));
aead.Init(&key);
return aead.Open(ciphertext, nonce, authenticated_data);
}
std::string Cryptor::HmacForStorage(std::string_view data) const {
crypto::HMAC hmac(crypto::HMAC::SHA256);
const std::string key = DeriveKey(Algorithm::kHmacSha256);
std::vector<uint8_t> digest(hmac.DigestLength());
CHECK(hmac.Init(key));
CHECK(hmac.Sign(data, digest.data(), hmac.DigestLength()));
// The keychain fields that store RP ID and User ID seem to only accept
// NSString (not NSData), so we HexEncode to ensure the result to be
// UTF-8-decodable.
return base::HexEncode(digest);
}
// static
std::optional<crypto::Aead::AeadAlgorithm> Cryptor::ToAeadAlgorithm(
Algorithm alg) {
switch (alg) {
case Algorithm::kAes256Gcm:
return crypto::Aead::AES_256_GCM;
case Algorithm::kAes256GcmSiv:
return crypto::Aead::AES_256_GCM_SIV;
case Algorithm::kHmacSha256:
NOTREACHED_IN_MIGRATION() << "invalid AEAD";
return std::nullopt;
}
}
std::string Cryptor::DeriveKey(Algorithm alg) const {
static constexpr size_t kKeyLength = 32u;
std::string key;
const uint8_t info = static_cast<uint8_t>(alg);
const bool hkdf_init =
::HKDF(reinterpret_cast<uint8_t*>(base::WriteInto(&key, kKeyLength + 1)),
kKeyLength, EVP_sha256(),
reinterpret_cast<const uint8_t*>(secret_.data()), secret_.size(),
nullptr /* salt */, 0, &info, 1);
DCHECK(hkdf_init);
return key;
}
} // namespace
// static
CredentialMetadata::Version CredentialMetadata::CurrentVersion() {
return CredentialMetadata::Version::kV4;
}
// static
CredentialMetadata CredentialMetadata::FromPublicKeyCredentialUserEntity(
const PublicKeyCredentialUserEntity& user,
bool is_resident) {
return CredentialMetadata(
/*version=*/CurrentVersion(),
/*user_id=*/user.id,
/*user_name=*/user.name.value_or(""),
/*user_display_name=*/user.display_name.value_or(""),
/*is_resident=*/is_resident,
// All new credentials use zero counters:
CredentialMetadata::SignCounter::kZero);
}
PublicKeyCredentialUserEntity
CredentialMetadata::ToPublicKeyCredentialUserEntity() const {
PublicKeyCredentialUserEntity user_entity(user_id);
if (!user_name.empty()) {
user_entity.name = user_name;
}
if (!user_display_name.empty()) {
user_entity.display_name = user_display_name;
}
return user_entity;
}
CredentialMetadata::CredentialMetadata(Version version,
std::vector<uint8_t> user_id,
std::string user_name,
std::string user_display_name,
bool is_resident,
SignCounter counter_type)
: version(version),
user_id(std::move(user_id)),
user_name(std::move(user_name)),
user_display_name(std::move(user_display_name)),
is_resident(is_resident),
sign_counter_type(counter_type) {}
CredentialMetadata::CredentialMetadata(const CredentialMetadata&) = default;
CredentialMetadata::CredentialMetadata(CredentialMetadata&&) = default;
CredentialMetadata& CredentialMetadata::operator=(const CredentialMetadata&) =
default;
CredentialMetadata& CredentialMetadata::operator=(CredentialMetadata&&) =
default;
CredentialMetadata::~CredentialMetadata() = default;
bool CredentialMetadata::operator==(const CredentialMetadata& other) const {
return version == other.version && user_id == other.user_id &&
user_name == other.user_name &&
user_display_name == other.user_display_name &&
is_resident == other.is_resident &&
sign_counter_type == other.sign_counter_type;
}
std::string GenerateCredentialMetadataSecret() {
static constexpr size_t kSecretSize = 32u;
std::string secret;
RAND_bytes(
reinterpret_cast<uint8_t*>(base::WriteInto(&secret, kSecretSize + 1)),
kSecretSize);
return secret;
}
static std::string MaybeTruncateWithTrailingEllipsis(const std::string& in) {
constexpr size_t kMaxLength = 70u;
if (in.size() <= kMaxLength) {
return in;
}
std::string out;
// CTAP authenticators are not supposed to truncate before 64 bytes, but
// there is no truncate-with-min-size method, so truncate to a 67 byte max
// instead. Adding the 3-byte ellipsis gets us to a maximum of 70 bytes.
base::TruncateUTF8ToByteSize(in, kMaxLength - 3, &out);
out += "…"; // HORIZONTAL ELLIPSIS (E2 80 A6).
return out;
}
std::vector<uint8_t> SealCredentialMetadata(
const std::string& secret,
const std::string& rp_id,
const CredentialMetadata& metadata) {
// We only encrypt the most recent CredentialMetadata scheme in practice,
// except for tests.
DCHECK_GE(metadata.version, CredentialMetadata::Version::kV3);
// CBOR-encode the CredentialMetadata. Then AES-GCM encrypt, and authenticate
// with the RP ID.
cbor::Value::ArrayValue cbor_metadata;
cbor_metadata.emplace_back(cbor::Value(metadata.user_id));
cbor_metadata.emplace_back(
cbor::Value(MaybeTruncateWithTrailingEllipsis(metadata.user_name),
cbor::Value::Type::BYTE_STRING));
cbor_metadata.emplace_back(
cbor::Value(MaybeTruncateWithTrailingEllipsis(metadata.user_display_name),
cbor::Value::Type::BYTE_STRING));
cbor_metadata.emplace_back(cbor::Value(metadata.is_resident));
cbor_metadata.emplace_back(
cbor::Value(static_cast<uint8_t>(metadata.sign_counter_type)));
std::optional<std::vector<uint8_t>> pt =
cbor::Writer::Write(cbor::Value(std::move(cbor_metadata)));
DCHECK(pt);
std::vector<uint8_t> nonce(kNonceLength);
RAND_bytes(nonce.data(), nonce.size()); // RAND_bytes always returns 1.
const std::vector<uint8_t> ct =
Cryptor(secret).Seal(Cryptor::Algorithm::kAes256Gcm, nonce, *pt,
MakeAad(metadata.version, rp_id));
// The Credential ID is the concatenation of nonce and ciphertext.
nonce.insert(nonce.end(), ct.begin(), ct.end());
return nonce;
}
// UnsealLegacyCredentialId attempts to decrypt a credential ID that has been
// encrypted under the scheme for version 0 or 1, which is:
// | version | nonce | AEAD(pt=CBOR(metadata), |
// | (1 byte) | (12 bytes) | nonce=nonce, |
// | | | ad=(version, rpID)) |
// In these versions, the `version` field is not part of the AEAD pt. Version 0
// also lacks the `is_resident` boolean inside the metadata (i.e. all V0
// credentials are non-resident).
static std::optional<CredentialMetadata> UnsealLegacyCredentialId(
const std::string& secret,
const std::string& rp_id,
base::span<const uint8_t> credential_id) {
// Recover the nonce and check for the correct version byte. Then try to
// decrypt the remaining bytes.
if (credential_id.size() <= 1 + kNonceLength ||
(credential_id[0] !=
static_cast<uint8_t>(CredentialMetadata::Version::kV0) &&
credential_id[0] !=
static_cast<uint8_t>(CredentialMetadata::Version::kV1))) {
return std::nullopt;
}
auto version = static_cast<CredentialMetadata::Version>(credential_id[0]);
std::optional<std::vector<uint8_t>> plaintext = Cryptor(secret).Unseal(
Cryptor::Algorithm::kAes256Gcm, credential_id.subspan(1, kNonceLength),
credential_id.subspan(1 + kNonceLength), MakeAad(version, rp_id));
if (!plaintext) {
return std::nullopt;
}
// The recovered plaintext should decode into the CredentialMetadata struct.
std::optional<cbor::Value> maybe_array = cbor::Reader::Read(*plaintext);
if (!maybe_array || !maybe_array->is_array()) {
return std::nullopt;
}
const cbor::Value::ArrayValue& array = maybe_array->GetArray();
if (array.size() < 3 || !array[0].is_bytestring() ||
!array[1].is_bytestring() || !array[2].is_bytestring()) {
return std::nullopt;
}
auto user_id = array[0].GetBytestring();
auto user_name = array[1].GetBytestringAsString();
auto user_display_name = array[2].GetBytestringAsString();
bool is_resident = false;
DCHECK(version == CredentialMetadata::Version::kV0 ||
version == CredentialMetadata::Version::kV1);
if (version == CredentialMetadata::Version::kV0 && array.size() != 3) {
return std::nullopt;
}
if (version == CredentialMetadata::Version::kV1) {
if (array.size() != 4 || !array[3].is_bool()) {
return std::nullopt;
}
is_resident = array[3].GetBool();
}
return CredentialMetadata(
/*version=*/version,
/*user_id=*/user_id,
/*user_name=*/std::string(user_name),
/*user_display_name=*/std::string(user_display_name),
/*is_resident=*/is_resident,
// V0 and V1 credentials implicitly use a timestamp counter.
CredentialMetadata::SignCounter::kTimestamp);
}
// Attempts to unseal metadata V2 or later, which dropped the unencrypted
// version prefix. Since the metadata version is still part of the AEAD's
// authenticated data, this is generally called iteratively for each potential
// version. Returns nullopt if unsealing fails.
static std::optional<CredentialMetadata> UnsealV2OrLaterCredentialMetadata(
CredentialMetadata::Version version,
const std::string& secret,
const std::string& rp_id,
base::span<const uint8_t> credential_id) {
DCHECK_GE(version, CredentialMetadata::Version::kV2);
if (credential_id.size() <= kNonceLength) {
return std::nullopt;
}
std::optional<std::vector<uint8_t>> plaintext = Cryptor(secret).Unseal(
Cryptor::Algorithm::kAes256Gcm, credential_id.first(kNonceLength),
credential_id.subspan(kNonceLength), MakeAad(version, rp_id));
if (!plaintext) {
return std::nullopt;
}
std::optional<cbor::Value> maybe_array = cbor::Reader::Read(base::make_span(
reinterpret_cast<const uint8_t*>(plaintext->data()), plaintext->size()));
if (!maybe_array || !maybe_array->is_array()) {
return std::nullopt;
}
const cbor::Value::ArrayValue& array = maybe_array->GetArray();
if (array.size() < 4 || !array[0].is_bytestring() ||
!array[1].is_bytestring() || !array[2].is_bytestring() ||
!array[3].is_bool()) {
return std::nullopt;
}
if (version == CredentialMetadata::Version::kV2) {
if (array.size() != 4) {
return std::nullopt;
}
return CredentialMetadata(
CredentialMetadata::Version::kV2, array[0].GetBytestring(),
std::string(array[1].GetBytestringAsString()),
std::string(array[2].GetBytestringAsString()), array[3].GetBool(),
// V2 credentials implicitly use a zero counter.
CredentialMetadata::SignCounter::kZero);
}
static_assert(
CredentialMetadata::Version::MAX_VERSION ==
CredentialMetadata::Version::kV4,
"Ensure unsealing code is able to handle added CredentialMetadata "
"versions");
DCHECK_GE(version, CredentialMetadata::Version::kV3);
if (array.size() != 5) {
return std::nullopt;
}
// Decode SignCounter enum:
const int64_t counter_type = array[4].GetUnsigned();
if (counter_type < 1) {
return std::nullopt;
}
return CredentialMetadata(version, array[0].GetBytestring(),
std::string(array[1].GetBytestringAsString()),
std::string(array[2].GetBytestringAsString()),
array[3].GetBool(),
CredentialMetadata::SignCounter(counter_type));
}
std::optional<CredentialMetadata> UnsealMetadataFromLegacyCredentialId(
const std::string& secret,
const std::string& rp_id,
base::span<const uint8_t> credential_id) {
// Trial decrypt under V2 first, and if that fails try again with V0/V1.
std::optional<CredentialMetadata> credential_metadata =
UnsealV2OrLaterCredentialMetadata(CredentialMetadata::Version::kV2,
secret, rp_id, credential_id);
if (credential_metadata) {
return credential_metadata;
}
return UnsealLegacyCredentialId(secret, rp_id, credential_id);
}
std::optional<CredentialMetadata> UnsealMetadataFromApplicationTag(
const std::string& secret,
const std::string& rp_id,
base::span<const uint8_t> application_tag) {
static_assert(
CredentialMetadata::Version::MAX_VERSION ==
CredentialMetadata::Version::kV4,
"Ensure unsealing code is able to handle added CredentialMetadata "
"versions");
// kSecAttrApplicationTag only stores >= V3 metadata. This needs trial
// decryption because the version is part of the AEAD authententication tag.
for (const auto version :
{CredentialMetadata::Version::kV3, CredentialMetadata::Version::kV4}) {
if (std::optional<CredentialMetadata> metadata =
UnsealV2OrLaterCredentialMetadata(version, secret, rp_id,
application_tag)) {
return metadata;
}
}
return std::nullopt;
}
std::string EncodeRpIdAndUserIdDeprecated(const std::string& secret,
const std::string& rp_id,
base::span<const uint8_t> user_id) {
// Encoding RP ID along with the user ID hides whether the same user ID was
// reused on different RPs.
const auto* user_id_data = reinterpret_cast<const char*>(user_id.data());
return Cryptor(secret).HmacForStorage(
rp_id + "/" + std::string(user_id_data, user_id_data + user_id.size()));
}
std::string EncodeRpId(const std::string& secret, const std::string& rp_id) {
// Encrypt with a fixed nonce to make the result deterministic while still
// allowing the RP ID to be recovered from the ciphertext later.
static constexpr std::array<uint8_t, kNonceLength> fixed_zero_nonce = {};
base::span<const uint8_t> pt(reinterpret_cast<const uint8_t*>(rp_id.data()),
rp_id.size());
// Using AES-GCM with a fixed nonce would break confidentiality, so this uses
// AES-GCM-SIV instead.
const std::vector<uint8_t> ct =
Cryptor(secret).Seal(Cryptor::Algorithm::kAes256GcmSiv, fixed_zero_nonce,
pt, /*authenticated_data=*/{});
// HexEncode to ensure that the result is valid UTF-8. The result of this
// function will be converted to an NSString via SysUTF8ToNSString and
// therefore must be valid for that.
return base::HexEncode(ct);
}
std::optional<std::string> DecodeRpId(const std::string& secret,
const std::string& ciphertext) {
std::vector<uint8_t> ct;
if (!base::HexStringToBytes(ciphertext, &ct)) {
return std::nullopt;
}
static constexpr std::array<uint8_t, kNonceLength> fixed_zero_nonce = {};
std::optional<std::vector<uint8_t>> pt = Cryptor(secret).Unseal(
Cryptor::Algorithm::kAes256GcmSiv, fixed_zero_nonce, ct,
/*authenticated_data=*/{});
if (!pt) {
return std::nullopt;
}
return std::string(pt->begin(), pt->end());
}
std::vector<uint8_t> SealLegacyCredentialIdForTestingOnly(
CredentialMetadata::Version version,
const std::string& secret,
const std::string& rp_id,
const std::vector<uint8_t>& user_id,
const std::string& user_name,
const std::string& user_display_name,
bool is_resident) {
DCHECK_LT(version, CredentialMetadata::Version::kV3);
std::vector<uint8_t> result;
if (version < CredentialMetadata::Version::kV2) {
result.push_back(static_cast<uint8_t>(version));
}
auto nonce_begin = result.insert(result.end(), 12, 0);
base::span<uint8_t> nonce(nonce_begin, result.end());
DCHECK_EQ(nonce.size(), 12u);
RAND_bytes(nonce.data(), nonce.size()); // RAND_bytes always returns 1.
// Only V1 includes the `is_resident` bit. `sign_counter_type=kTimestamp` was
// implicit before V3 and thus not encoded.
cbor::Value::ArrayValue cbor_metadata;
cbor_metadata.emplace_back(cbor::Value(user_id));
cbor_metadata.emplace_back(
cbor::Value(user_name, cbor::Value::Type::BYTE_STRING));
cbor_metadata.emplace_back(
cbor::Value(user_display_name, cbor::Value::Type::BYTE_STRING));
DCHECK(version > CredentialMetadata::Version::kV0 || !is_resident);
if (version > CredentialMetadata::Version::kV0) {
cbor_metadata.emplace_back(cbor::Value(is_resident));
}
std::optional<std::vector<uint8_t>> pt =
cbor::Writer::Write(cbor::Value(std::move(cbor_metadata)));
DCHECK(pt);
std::vector<uint8_t> aad;
aad.push_back(static_cast<uint8_t>(version));
aad.insert(aad.end(), rp_id.data(), rp_id.data() + rp_id.size());
const std::vector<uint8_t> ct =
Cryptor(secret).Seal(Cryptor::Algorithm::kAes256Gcm, nonce, *pt, aad);
result.insert(result.end(), ct.begin(), ct.end());
return result;
}
} // namespace device::fido::mac