523 lines
15 KiB
Text
523 lines
15 KiB
Text
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "platform/mac/wallet_protection_mac.h"
|
|
|
|
#include "base/platform/mac/base_utilities_mac.h"
|
|
#include "base/random.h"
|
|
#include "base/system_unlock.h"
|
|
#include "lang/lang_keys.h"
|
|
#include "storage/serialize_common.h"
|
|
#include "wallet/wallet_key_protection.h"
|
|
#include "wallet/wallet_vault.h"
|
|
|
|
#include <Foundation/Foundation.h>
|
|
#include <LocalAuthentication/LocalAuthentication.h>
|
|
#include <Security/Security.h>
|
|
|
|
namespace Platform {
|
|
namespace {
|
|
|
|
using Wallet::ProtectionError;
|
|
|
|
constexpr auto kPayloadVersion = quint32(1);
|
|
|
|
// kSecAttrTokenOID, undeclared in the public SDK: the SEP-wrapped key blob
|
|
// CryptoKit persists as a Secure Enclave key's dataRepresentation. It must
|
|
// travel as this ATTRIBUTE - keyData is ignored for a token key, and the
|
|
// blob passed as keyData alone silently mints a new key (probe variants
|
|
// A/C/D against B/H/J); EnclaveRoundTrips() below detects that shape.
|
|
const auto kTokenOIDAttribute = CFSTR("toid");
|
|
|
|
const auto kAlgorithm
|
|
= kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM;
|
|
|
|
const auto kCryptoTokenKitDomain = CFSTR("CryptoTokenKit");
|
|
constexpr auto kCryptoTokenKitCorruptedData = CFIndex(-3);
|
|
constexpr auto kCryptoTokenKitCanceledByUser = CFIndex(-4);
|
|
constexpr auto kCryptoTokenKitAuthenticationFailed = CFIndex(-5);
|
|
|
|
struct Payload {
|
|
QByteArray keyBlob;
|
|
QByteArray sealedWrapKey;
|
|
};
|
|
|
|
struct UnwrapOperation {
|
|
~UnwrapOperation();
|
|
|
|
LAContext *context = nil;
|
|
SecKeyRef key = nullptr;
|
|
QByteArray sealedWrapKey;
|
|
Wallet::VaultWrap wrap;
|
|
Fn<void(Wallet::ProtectionUnwrapResult)> done;
|
|
};
|
|
|
|
[[nodiscard]] QByteArray SerializePayload(const Payload &payload) {
|
|
auto stream = Serialize::ByteArrayWriter(int(sizeof(quint32))
|
|
+ Serialize::bytearraySize(payload.keyBlob)
|
|
+ Serialize::bytearraySize(payload.sealedWrapKey));
|
|
stream << kPayloadVersion << payload.keyBlob << payload.sealedWrapKey;
|
|
return std::move(stream).result();
|
|
}
|
|
|
|
[[nodiscard]] std::optional<Payload> ParsePayload(const QByteArray &data) {
|
|
auto stream = Serialize::ByteArrayReader(data);
|
|
auto version = quint32();
|
|
auto result = Payload();
|
|
stream >> version >> result.keyBlob >> result.sealedWrapKey;
|
|
if (!stream.ok()
|
|
|| !stream.atEnd()
|
|
|| version != kPayloadVersion
|
|
|| result.keyBlob.isEmpty()
|
|
|| result.sealedWrapKey.isEmpty()) {
|
|
return std::nullopt;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
template <typename T>
|
|
[[nodiscard]] auto ReleaseLater(T &ref) {
|
|
return gsl::finally([&] {
|
|
if (ref) {
|
|
CFRelease(ref);
|
|
}
|
|
});
|
|
}
|
|
|
|
void LogError(CFErrorRef error) {
|
|
if (!error) {
|
|
return;
|
|
}
|
|
const auto domain = CFErrorGetDomain(error);
|
|
LOG(("Wallet Error: Touch ID protection failed, domain '%1', code %2."
|
|
).arg(domain ? QString::fromCFString(domain) : QString()
|
|
).arg(CFErrorGetCode(error)));
|
|
}
|
|
|
|
[[nodiscard]] QByteArray RandomBytes(int size) {
|
|
auto result = QByteArray(size, Qt::Uninitialized);
|
|
base::RandomFill(result.data(), result.size());
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] QByteArray TokenBlob(SecKeyRef key) {
|
|
const auto attributes = SecKeyCopyAttributes(key);
|
|
if (!attributes) {
|
|
return QByteArray();
|
|
}
|
|
const auto releaseAttributes = ReleaseLater(attributes);
|
|
const auto value = CFDictionaryGetValue(attributes, kTokenOIDAttribute);
|
|
return (value && CFGetTypeID(value) == CFDataGetTypeID())
|
|
? QByteArray::fromCFData((CFDataRef)value)
|
|
: QByteArray();
|
|
}
|
|
|
|
[[nodiscard]] QByteArray PublicKeyBytes(SecKeyRef key) {
|
|
const auto publicKey = SecKeyCopyPublicKey(key);
|
|
if (!publicKey) {
|
|
return QByteArray();
|
|
}
|
|
const auto releasePublicKey = ReleaseLater(publicKey);
|
|
CFErrorRef error = nullptr;
|
|
const auto releaseError = ReleaseLater(error);
|
|
const auto data = SecKeyCopyExternalRepresentation(publicKey, &error);
|
|
if (!data) {
|
|
return QByteArray();
|
|
}
|
|
const auto releaseData = ReleaseLater(data);
|
|
return QByteArray::fromCFData(data);
|
|
}
|
|
|
|
[[nodiscard]] SecKeyRef CreateEnclaveKey(CFErrorRef *error) {
|
|
const auto access = SecAccessControlCreateWithFlags(
|
|
kCFAllocatorDefault,
|
|
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
|
kSecAccessControlPrivateKeyUsage | kSecAccessControlUserPresence,
|
|
error);
|
|
if (!access) {
|
|
return nullptr;
|
|
}
|
|
const auto releaseAccess = ReleaseLater(access);
|
|
NSDictionary *attributes = @{
|
|
(id)kSecAttrKeyType: (id)kSecAttrKeyTypeECSECPrimeRandom,
|
|
(id)kSecAttrKeySizeInBits: @256,
|
|
(id)kSecAttrTokenID: (id)kSecAttrTokenIDSecureEnclave,
|
|
(id)kSecPrivateKeyAttrs: @{
|
|
(id)kSecAttrIsPermanent: @NO,
|
|
(id)kSecAttrAccessControl: (id)access,
|
|
},
|
|
};
|
|
return SecKeyCreateRandomKey((CFDictionaryRef)attributes, error);
|
|
}
|
|
|
|
[[nodiscard]] SecKeyRef RestoreEnclaveKey(
|
|
const QByteArray &blob,
|
|
LAContext *context,
|
|
CFErrorRef *error) {
|
|
NSDictionary *attributes = @{
|
|
(id)kSecAttrKeyType: (id)kSecAttrKeyTypeECSECPrimeRandom,
|
|
(id)kSecAttrKeyClass: (id)kSecAttrKeyClassPrivate,
|
|
(id)kSecAttrTokenID: (id)kSecAttrTokenIDSecureEnclave,
|
|
(id)kTokenOIDAttribute: blob.toNSData(),
|
|
(id)kSecAttrKeySizeInBits: @256,
|
|
(id)kSecUseAuthenticationContext: context,
|
|
};
|
|
return SecKeyCreateWithData(
|
|
(CFDataRef)[NSData data],
|
|
(CFDictionaryRef)attributes,
|
|
error);
|
|
}
|
|
|
|
[[nodiscard]] bool OwnerAuthenticationPossible() {
|
|
return rpl::variable<base::SystemUnlockAvailability>(
|
|
base::SystemUnlockStatus()).current().available;
|
|
}
|
|
|
|
[[nodiscard]] bool EnclaveRoundTrips() {
|
|
CFErrorRef error = nullptr;
|
|
const auto releaseError = ReleaseLater(error);
|
|
NSDictionary *attributes = @{
|
|
(id)kSecAttrKeyType: (id)kSecAttrKeyTypeECSECPrimeRandom,
|
|
(id)kSecAttrKeySizeInBits: @256,
|
|
(id)kSecAttrTokenID: (id)kSecAttrTokenIDSecureEnclave,
|
|
(id)kSecPrivateKeyAttrs: @{ (id)kSecAttrIsPermanent: @NO },
|
|
};
|
|
const auto original = SecKeyCreateRandomKey(
|
|
(CFDictionaryRef)attributes,
|
|
&error);
|
|
if (!original) {
|
|
LogError(error);
|
|
return false;
|
|
}
|
|
const auto releaseOriginal = ReleaseLater(original);
|
|
const auto blob = TokenBlob(original);
|
|
if (blob.isEmpty()) {
|
|
return false;
|
|
}
|
|
LAContext *context = [[LAContext alloc] init];
|
|
const auto releaseContext = gsl::finally([&] { [context release]; });
|
|
context.interactionNotAllowed = YES;
|
|
const auto restored = RestoreEnclaveKey(blob, context, &error);
|
|
if (!restored) {
|
|
LogError(error);
|
|
return false;
|
|
}
|
|
const auto releaseRestored = ReleaseLater(restored);
|
|
const auto publicKey = PublicKeyBytes(original);
|
|
return !publicKey.isEmpty() && (PublicKeyBytes(restored) == publicKey);
|
|
}
|
|
|
|
[[nodiscard]] bool EvaluateAvailability() {
|
|
@autoreleasepool {
|
|
|
|
return OwnerAuthenticationPossible() && EnclaveRoundTrips();
|
|
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] ProtectionError ClassifyAuthentication(CFIndex code) {
|
|
switch (code) {
|
|
case LAErrorUserCancel:
|
|
case LAErrorUserFallback:
|
|
case LAErrorSystemCancel:
|
|
case LAErrorAppCancel:
|
|
return ProtectionError::Cancelled;
|
|
case LAErrorAuthenticationFailed:
|
|
case LAErrorBiometryLockout:
|
|
return ProtectionError::AuthenticationFailed;
|
|
}
|
|
return ProtectionError::Unavailable;
|
|
}
|
|
|
|
[[nodiscard]] ProtectionError ClassifyToken(CFIndex code) {
|
|
switch (code) {
|
|
case kCryptoTokenKitCorruptedData:
|
|
return ProtectionError::Absent;
|
|
case kCryptoTokenKitCanceledByUser:
|
|
return ProtectionError::Cancelled;
|
|
case kCryptoTokenKitAuthenticationFailed:
|
|
return ProtectionError::AuthenticationFailed;
|
|
}
|
|
return ProtectionError::Unavailable;
|
|
}
|
|
|
|
[[nodiscard]] ProtectionError ClassifyStatus(CFIndex code) {
|
|
switch (code) {
|
|
case errSecUserCanceled:
|
|
return ProtectionError::Cancelled;
|
|
case errSecAuthFailed:
|
|
return ProtectionError::AuthenticationFailed;
|
|
case errSecParam:
|
|
return ProtectionError::Corrupt;
|
|
}
|
|
return ProtectionError::Unavailable;
|
|
}
|
|
|
|
// The verdict follows the CFError's domain and code alone. Only
|
|
// CryptoTokenKit's corrupted-blob code is Absent: the probe proved it at
|
|
// restore and at first use, before any authentication and without UI. The
|
|
// sheet's cancels and refusals are typed, an ECIES tag failure after
|
|
// presence is Corrupt, and everything else - a not-interactive or invalid
|
|
// context included - stays Unavailable, which preserves the keyring. A
|
|
// foreign Mac's blob is expected to surface as that same corrupted-blob
|
|
// code, its SEP wrap failing its own authentication; that is assumed from
|
|
// one Mac, not established, and a different code lands Unavailable, the
|
|
// fail-safe side. CryptoTokenKit's codes are spelled out above because its
|
|
// header is not among this file's.
|
|
[[nodiscard]] ProtectionError Classify(CFErrorRef error) {
|
|
if (!error) {
|
|
return ProtectionError::Unavailable;
|
|
}
|
|
LogError(error);
|
|
const auto domain = CFErrorGetDomain(error);
|
|
const auto code = CFErrorGetCode(error);
|
|
const auto matches = [&](CFStringRef expected) {
|
|
return domain
|
|
&& (CFStringCompare(domain, expected, 0) == kCFCompareEqualTo);
|
|
};
|
|
return matches((CFStringRef)LAErrorDomain)
|
|
? ClassifyAuthentication(code)
|
|
: matches(kCryptoTokenKitDomain)
|
|
? ClassifyToken(code)
|
|
: matches((CFStringRef)NSOSStatusErrorDomain)
|
|
? ClassifyStatus(code)
|
|
: ProtectionError::Unavailable;
|
|
}
|
|
|
|
[[nodiscard]] QByteArray SealWrapKey(
|
|
SecKeyRef key,
|
|
const Wallet::SecureBytes &wrapKey,
|
|
CFErrorRef *error) {
|
|
const auto publicKey = SecKeyCopyPublicKey(key);
|
|
if (!publicKey) {
|
|
return QByteArray();
|
|
}
|
|
const auto releasePublicKey = ReleaseLater(publicKey);
|
|
const auto span = wrapKey.span();
|
|
const auto plain = CFDataCreateWithBytesNoCopy(
|
|
kCFAllocatorDefault,
|
|
reinterpret_cast<const UInt8*>(span.data()),
|
|
span.size(),
|
|
kCFAllocatorNull);
|
|
const auto releasePlain = ReleaseLater(plain);
|
|
const auto sealed = SecKeyCreateEncryptedData(
|
|
publicKey,
|
|
kAlgorithm,
|
|
plain,
|
|
error);
|
|
if (!sealed) {
|
|
return QByteArray();
|
|
}
|
|
const auto releaseSealed = ReleaseLater(sealed);
|
|
return QByteArray::fromCFData(sealed);
|
|
}
|
|
|
|
[[nodiscard]] Wallet::ProtectionEnrollResult Enroll() {
|
|
@autoreleasepool {
|
|
|
|
CFErrorRef error = nullptr;
|
|
const auto releaseError = ReleaseLater(error);
|
|
const auto key = CreateEnclaveKey(&error);
|
|
if (!key) {
|
|
LogError(error);
|
|
return { .error = ProtectionError::Unavailable };
|
|
}
|
|
const auto releaseKey = ReleaseLater(key);
|
|
auto payload = Payload{ .keyBlob = TokenBlob(key) };
|
|
if (payload.keyBlob.isEmpty()) {
|
|
return { .error = ProtectionError::Unavailable };
|
|
}
|
|
auto wrapKey = Wallet::SecureBytes(Wallet::kVaultKeySize);
|
|
base::RandomFill(wrapKey.span());
|
|
payload.sealedWrapKey = SealWrapKey(key, wrapKey, &error);
|
|
if (payload.sealedWrapKey.isEmpty()) {
|
|
LogError(error);
|
|
return { .error = ProtectionError::Unavailable };
|
|
}
|
|
return {
|
|
.wrap = Wallet::VaultPreparedWrap{
|
|
.wrap = Wallet::VaultWrap{
|
|
.kind = Wallet::VaultKind::TouchId,
|
|
.salt = RandomBytes(Wallet::kVaultSaltSize),
|
|
.openSecret = SerializePayload(payload),
|
|
},
|
|
.wrapKey = std::move(wrapKey),
|
|
},
|
|
.error = ProtectionError::None,
|
|
};
|
|
|
|
}
|
|
}
|
|
|
|
[[nodiscard]] ProtectionError PrepareUnwrap(
|
|
UnwrapOperation &op,
|
|
const QByteArray &keyBlob) {
|
|
@autoreleasepool {
|
|
|
|
CFErrorRef error = nullptr;
|
|
const auto releaseError = ReleaseLater(error);
|
|
op.context = [[LAContext alloc] init];
|
|
op.context.localizedReason = Q2NSString(
|
|
tr::lng_wallet_protection_touchid_reason(tr::now));
|
|
op.key = RestoreEnclaveKey(keyBlob, op.context, &error);
|
|
return op.key ? ProtectionError::None : Classify(error);
|
|
|
|
}
|
|
}
|
|
|
|
// The plaintext CFData is a copy of the wrap key that cannot be cleansed;
|
|
// it lives only until this function returns.
|
|
[[nodiscard]] Wallet::SecureBytes DecryptWrapKey(
|
|
const UnwrapOperation &op,
|
|
CFErrorRef *error) {
|
|
const auto sealed = op.sealedWrapKey.toCFData();
|
|
const auto releaseSealed = ReleaseLater(sealed);
|
|
const auto plain = SecKeyCreateDecryptedData(
|
|
op.key,
|
|
kAlgorithm,
|
|
sealed,
|
|
error);
|
|
if (!plain) {
|
|
return Wallet::SecureBytes();
|
|
}
|
|
const auto releasePlain = ReleaseLater(plain);
|
|
return Wallet::SecureBytes(bytes::make_span(
|
|
CFDataGetBytePtr(plain),
|
|
CFDataGetLength(plain)));
|
|
}
|
|
|
|
[[nodiscard]] Wallet::ProtectionUnwrapResult Unseal(
|
|
const UnwrapOperation &op) {
|
|
CFErrorRef error = nullptr;
|
|
const auto releaseError = ReleaseLater(error);
|
|
const auto wrapKey = DecryptWrapKey(op, &error);
|
|
if (wrapKey.empty()) {
|
|
return { .error = Classify(error) };
|
|
}
|
|
auto key = Wallet::UnwrapVaultKey(op.wrap, wrapKey);
|
|
if (!key) {
|
|
return { .error = ProtectionError::Corrupt };
|
|
}
|
|
return { .key = std::move(key), .error = ProtectionError::None };
|
|
}
|
|
|
|
void AnswerUnwrap(
|
|
Fn<void(Wallet::ProtectionUnwrapResult)> done,
|
|
ProtectionError error) {
|
|
crl::on_main([done = std::move(done), error] {
|
|
done({ .error = error });
|
|
});
|
|
}
|
|
|
|
class TouchIdProtection final : public Wallet::ProtectionProvider {
|
|
public:
|
|
[[nodiscard]] Wallet::VaultKind kind() const override;
|
|
[[nodiscard]] rpl::producer<bool> available() const override;
|
|
[[nodiscard]] rpl::producer<QString> title() const override;
|
|
[[nodiscard]] rpl::producer<QString> description() const override;
|
|
[[nodiscard]] rpl::producer<QString> label() const override;
|
|
|
|
void enroll(
|
|
not_null<Storage::Account*> local,
|
|
Fn<void(Wallet::ProtectionEnrollResult)> done) override;
|
|
void unwrap(
|
|
not_null<Storage::Account*> local,
|
|
Wallet::VaultWrap wrap,
|
|
Fn<void(Wallet::ProtectionUnwrapResult)> done) override;
|
|
void remove(
|
|
not_null<Storage::Account*> local,
|
|
Wallet::VaultWrap wrap,
|
|
Fn<void(ProtectionError)> done) override;
|
|
|
|
private:
|
|
mutable std::optional<rpl::variable<bool>> _available;
|
|
|
|
};
|
|
|
|
UnwrapOperation::~UnwrapOperation() {
|
|
[context release];
|
|
if (key) {
|
|
CFRelease(key);
|
|
}
|
|
}
|
|
|
|
Wallet::VaultKind TouchIdProtection::kind() const {
|
|
return Wallet::VaultKind::TouchId;
|
|
}
|
|
|
|
rpl::producer<bool> TouchIdProtection::available() const {
|
|
// Evaluated once: neither fact changes meaningfully within a process.
|
|
if (!_available) {
|
|
_available.emplace(EvaluateAvailability());
|
|
}
|
|
return _available->value();
|
|
}
|
|
|
|
rpl::producer<QString> TouchIdProtection::title() const {
|
|
return tr::lng_settings_use_touchid();
|
|
}
|
|
|
|
rpl::producer<QString> TouchIdProtection::description() const {
|
|
return tr::lng_wallet_protection_touchid_about();
|
|
}
|
|
|
|
rpl::producer<QString> TouchIdProtection::label() const {
|
|
return tr::lng_wallet_protection_touchid_label();
|
|
}
|
|
|
|
void TouchIdProtection::enroll(
|
|
not_null<Storage::Account*> local,
|
|
Fn<void(Wallet::ProtectionEnrollResult)> done) {
|
|
crl::on_main([done = std::move(done), result = Enroll()]() mutable {
|
|
done(std::move(result));
|
|
});
|
|
}
|
|
|
|
void TouchIdProtection::unwrap(
|
|
not_null<Storage::Account*> local,
|
|
Wallet::VaultWrap wrap,
|
|
Fn<void(Wallet::ProtectionUnwrapResult)> done) {
|
|
auto payload = ParsePayload(wrap.openSecret);
|
|
if (!payload) {
|
|
AnswerUnwrap(std::move(done), ProtectionError::Corrupt);
|
|
return;
|
|
}
|
|
auto op = std::make_shared<UnwrapOperation>();
|
|
op->sealedWrapKey = std::move(payload->sealedWrapKey);
|
|
op->wrap = std::move(wrap);
|
|
op->done = std::move(done);
|
|
const auto error = PrepareUnwrap(*op, payload->keyBlob);
|
|
if (error != ProtectionError::None) {
|
|
AnswerUnwrap(std::move(op->done), error);
|
|
return;
|
|
}
|
|
crl::async([op = std::move(op)]() mutable {
|
|
auto result = Unseal(*op);
|
|
crl::on_main([op = std::move(op), result = std::move(result)]() mutable {
|
|
op->done(std::move(result));
|
|
});
|
|
});
|
|
}
|
|
|
|
void TouchIdProtection::remove(
|
|
not_null<Storage::Account*> local,
|
|
Wallet::VaultWrap wrap,
|
|
Fn<void(ProtectionError)> done) {
|
|
// The enclave key exists only in the retired device wrap's blob.
|
|
crl::on_main([done = std::move(done)] {
|
|
done(ProtectionError::None);
|
|
});
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void RegisterWalletProtectionProvider() {
|
|
Wallet::RegisterProtectionProvider(
|
|
std::make_unique<TouchIdProtection>());
|
|
}
|
|
|
|
} // namespace Platform
|