Adjust SVR2PinHash protocol

This commit is contained in:
Max Radermacher 2026-05-20 01:28:44 -05:00 committed by GitHub
parent 7a30fc750e
commit 7c3a73d1a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 107 additions and 179 deletions

View File

@ -783,6 +783,7 @@
509DC8DA2BCED88600375E86 /* RemoteMegaphoneFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D98DD85D28EE53B00089333E /* RemoteMegaphoneFetcher.swift */; };
50A156C72FA11AA8008FE086 /* Fingerprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A156C62FA11AA8008FE086 /* Fingerprint.swift */; };
50A1CE3A2A00931900730C40 /* DebugLogger+MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A1CE392A00931900730C40 /* DebugLogger+MainApp.swift */; };
50A26F1A2FB6991F000A2D8B /* SVR2PinHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A26F192FB6991F000A2D8B /* SVR2PinHash.swift */; };
50A40ED32B88005A0060C5A5 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A40ED22B88005A0060C5A5 /* DisplayName.swift */; };
50A4AC622C111FAE00D89C8E /* CallLinkAuthCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A4AC612C111FAE00D89C8E /* CallLinkAuthCredential.swift */; };
50A5AA992A7449A100CF2ECC /* DecryptedIncomingEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A5AA982A7449A100CF2ECC /* DecryptedIncomingEnvelope.swift */; };
@ -5054,6 +5055,7 @@
50A156C62FA11AA8008FE086 /* Fingerprint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fingerprint.swift; sourceTree = "<group>"; };
50A1CE372A00894C00730C40 /* DebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLogger.swift; sourceTree = "<group>"; };
50A1CE392A00931900730C40 /* DebugLogger+MainApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DebugLogger+MainApp.swift"; sourceTree = "<group>"; };
50A26F192FB6991F000A2D8B /* SVR2PinHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SVR2PinHash.swift; sourceTree = "<group>"; };
50A40ED22B88005A0060C5A5 /* DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayName.swift; sourceTree = "<group>"; };
50A4AC612C111FAE00D89C8E /* CallLinkAuthCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallLinkAuthCredential.swift; sourceTree = "<group>"; };
50A5AA982A7449A100CF2ECC /* DecryptedIncomingEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecryptedIncomingEnvelope.swift; sourceTree = "<group>"; };
@ -10697,6 +10699,7 @@
children = (
662C440A2A156DF7001F83E2 /* SecureValueRecovery2Impl.swift */,
66C2B13C2A0E9116008DDE72 /* SVR2AuthCredential.swift */,
50A26F192FB6991F000A2D8B /* SVR2PinHash.swift */,
669947B92A20129000E4DC0C /* SVR2Shims.swift */,
66C2B1552A1400E8008DDE72 /* SVR2WebsocketConfigurator.swift */,
);
@ -19903,6 +19906,7 @@
D945319E2CE53CEB004DAB30 /* SubscriptionRedemptionNecessityChecker.swift in Sources */,
662C44092A1567E4001F83E2 /* svr2.pb.swift in Sources */,
66C2B13D2A0E9116008DDE72 /* SVR2AuthCredential.swift in Sources */,
50A26F1A2FB6991F000A2D8B /* SVR2PinHash.swift in Sources */,
669947BA2A20129000E4DC0C /* SVR2Shims.swift in Sources */,
66C2B1562A1400E8008DDE72 /* SVR2WebsocketConfigurator.swift in Sources */,
66C2B1382A0DB6A9008DDE72 /* SVRAuthCredential.swift in Sources */,

View File

@ -446,6 +446,7 @@ extension AppSetup.GlobalsContinuation {
credentialStorage: svrCredentialStorage,
db: db,
accountKeyStore: accountKeyStore,
pinHasher: LibSignalPinHasher(),
storageServiceManager: storageServiceManager,
svrLocalStorage: svrLocalStorage,
tsAccountManager: tsAccountManager,

View File

@ -0,0 +1,78 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import CryptoKit
import Foundation
import LibSignalClient
protocol SVR2PinHasher {
func hashPin(
normalizedPin: String,
username: String,
mrEnclave: MrEnclave,
) throws -> SVR2PinHash
}
struct LibSignalPinHasher: SVR2PinHasher {
func hashPin(normalizedPin: String, username: String, mrEnclave: MrEnclave) throws -> SVR2PinHash {
return try SVR2PinHash.derive(normalizedPin: normalizedPin, username: username, mrEnclave: mrEnclave)
}
}
#if TESTABLE_BUILD
/// A "mock" PIN hasher that works even for invalid MrEnclave values.
struct MockPinHasher: SVR2PinHasher {
func hashPin(normalizedPin: String, username: String, mrEnclave: MrEnclave) throws -> SVR2PinHash {
// A fake hashing function that considers the same inputs as the real one.
let result = try hkdf(outputLength: 64, inputKeyMaterial: Data(normalizedPin.utf8), salt: Data(username.utf8), info: mrEnclave.dataValue)
return SVR2PinHash(accessKey: result.prefix(32), encryptionKey: result.suffix(32))
}
}
#endif
struct SVR2PinHash {
// The thing we use as the "pin" in SVR2 backup/restore requests.
var accessKey: Data
var encryptionKey: Data
// The data we put into SVR2 backups, encrypted with the PIN.
func encryptMasterKey(_ masterKey: Data) throws -> Data {
let (iv, cipherText) = try Sha256HmacSiv.encrypt(data: masterKey, key: encryptionKey)
if iv.count != 16 || cipherText.count != 32 {
throw SVR.SVRError.assertion
}
let encryptedMasterKey = iv + cipherText
return encryptedMasterKey
}
func decryptMasterKey(_ encryptedMasterKey: Data) throws -> Data {
guard encryptedMasterKey.count == 48 else { throw SVR.SVRError.assertion }
let startIndex: Int = encryptedMasterKey.startIndex
let ivRange = startIndex...(startIndex + 15)
let cipherRange = (startIndex + 16)...(startIndex + 47)
let masterKey = try Sha256HmacSiv.decrypt(
iv: encryptedMasterKey[ivRange],
cipherText: encryptedMasterKey[cipherRange],
key: encryptionKey,
)
guard masterKey.count == MasterKey.Constants.byteLength else { throw SVR.SVRError.assertion }
return masterKey
}
static func derive(
normalizedPin: String,
username: String,
mrEnclave: MrEnclave,
) throws -> Self {
let pinHash = try LibSignalClient.PinHash(normalizedPin: Data(normalizedPin.utf8), username: username, mrenclave: mrEnclave.dataValue)
return Self(accessKey: pinHash.accessKey, encryptionKey: pinHash.encryptionKey)
}
}

View File

@ -57,81 +57,6 @@ public class _SVR2_OWS2FAManagerWrapper: SVR2.Shims.OWS2FAManager {
}
}
// NOTE: The below aren't shims/wrappers in the normal sense; they
// wrap libsignal stuff that we will _always_ need to wrap.
protocol SVR2PinHash {
// The thing we use as the "pin" in SVR2 backup/restore requests.
var accessKey: Data { get }
// The data we put into SVR2 backups, encrypted with the PIN.
func encryptMasterKey(_ masterKey: Data) throws -> Data
func decryptMasterKey(_ encryptedMasterKey: Data) throws -> Data
}
protocol SVR2ClientWrapper {
func hashPin(
connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>,
utf8NormalizedPin: Data,
username: String,
) throws -> SVR2PinHash
}
class SVR2ClientWrapperImpl: SVR2ClientWrapper {
init() {}
private class SVR2PinHashImpl: SVR2PinHash {
private let pinHash: PinHash
init(_ pinHash: PinHash) {
self.pinHash = pinHash
}
var accessKey: Data { pinHash.accessKey }
private var encryptionKey: Data { pinHash.encryptionKey }
func encryptMasterKey(_ masterKey: Data) throws -> Data {
let (iv, cipherText) = try Sha256HmacSiv.encrypt(data: masterKey, key: encryptionKey)
if iv.count != 16 || cipherText.count != 32 {
throw SVR.SVRError.assertion
}
let encryptedMasterKey = iv + cipherText
return encryptedMasterKey
}
func decryptMasterKey(_ encryptedMasterKey: Data) throws -> Data {
guard encryptedMasterKey.count == 48 else { throw SVR.SVRError.assertion }
let startIndex: Int = encryptedMasterKey.startIndex
let ivRange = startIndex...(startIndex + 15)
let cipherRange = (startIndex + 16)...(startIndex + 47)
let masterKey = try Sha256HmacSiv.decrypt(
iv: encryptedMasterKey[ivRange],
cipherText: encryptedMasterKey[cipherRange],
key: encryptionKey,
)
guard masterKey.count == MasterKey.Constants.byteLength else { throw SVR.SVRError.assertion }
return masterKey
}
}
func hashPin(
connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>,
utf8NormalizedPin: Data,
username: String,
) throws -> SVR2PinHash {
let pinHash = try LibSignalClient.PinHash(normalizedPin: utf8NormalizedPin, username: username, mrenclave: connection.mrEnclave.dataValue)
return SVR2PinHashImpl(pinHash)
}
}
#if TESTABLE_BUILD
extension SVR2 {
@ -149,43 +74,4 @@ class _SVR2_AppContextMock: _SVR2_AppContextShim {
var isNSE: Bool { false }
}
class MockSVR2ClientWrapper: SVR2ClientWrapper {
init() {}
class MockSVR2PinHash: SVR2PinHash {
init(utf8NormalizedPin: Data) {
self.accessKey = utf8NormalizedPin
}
var accessKey: Data
var didEncryptMasterKey: (_ masterKey: Data) throws -> Data = { return $0 }
func encryptMasterKey(_ masterKey: Data) throws -> Data {
return try didEncryptMasterKey(masterKey)
}
var didDecryptMasterKey: (_ encryptedMasterKey: Data) throws -> Data = { return $0 }
func decryptMasterKey(_ encryptedMasterKey: Data) throws -> Data {
return try didDecryptMasterKey(encryptedMasterKey)
}
}
var didHashPin: ((_ utf8NormalizedPin: Data, _ username: String) throws -> MockSVR2PinHash) = { utf8NormalizedPin, _ in
return MockSVR2PinHash(utf8NormalizedPin: utf8NormalizedPin)
}
func hashPin(
connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>,
utf8NormalizedPin: Data,
username: String,
) throws -> SVR2PinHash {
return try didHashPin(utf8NormalizedPin, username)
}
}
#endif

View File

@ -12,7 +12,7 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
private let appContext: SVR2.Shims.AppContext
private let appReadiness: AppReadiness
private let appVersion: AppVersion
private let clientWrapper: SVR2ClientWrapper
private let pinHasher: any SVR2PinHasher
private let connectionFactory: SgxWebsocketConnectionFactory
private let credentialStorage: SVRAuthCredentialStorage
private let db: any DB
@ -23,46 +23,15 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
private let tsConstants: TSConstantsProtocol
private let twoFAManager: SVR2.Shims.OWS2FAManager
public convenience init(
appContext: SVR2.Shims.AppContext,
appReadiness: AppReadiness,
appVersion: AppVersion,
connectionFactory: SgxWebsocketConnectionFactory,
credentialStorage: SVRAuthCredentialStorage,
db: any DB,
accountKeyStore: AccountKeyStore,
storageServiceManager: StorageServiceManager,
svrLocalStorage: SVRLocalStorageInternal,
tsAccountManager: TSAccountManager,
tsConstants: TSConstantsProtocol,
twoFAManager: SVR2.Shims.OWS2FAManager,
) {
self.init(
appContext: appContext,
appReadiness: appReadiness,
appVersion: appVersion,
clientWrapper: SVR2ClientWrapperImpl(),
connectionFactory: connectionFactory,
credentialStorage: credentialStorage,
db: db,
accountKeyStore: accountKeyStore,
storageServiceManager: storageServiceManager,
svrLocalStorage: svrLocalStorage,
tsAccountManager: tsAccountManager,
tsConstants: tsConstants,
twoFAManager: twoFAManager,
)
}
init(
appContext: SVR2.Shims.AppContext,
appReadiness: AppReadiness,
appVersion: AppVersion,
clientWrapper: SVR2ClientWrapper,
connectionFactory: SgxWebsocketConnectionFactory,
credentialStorage: SVRAuthCredentialStorage,
db: any DB,
accountKeyStore: AccountKeyStore,
pinHasher: any SVR2PinHasher,
storageServiceManager: StorageServiceManager,
svrLocalStorage: SVRLocalStorageInternal,
tsAccountManager: TSAccountManager,
@ -72,12 +41,12 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
self.appContext = appContext
self.appReadiness = appReadiness
self.appVersion = appVersion
self.clientWrapper = clientWrapper
self.connectionFactory = connectionFactory
self.credentialStorage = credentialStorage
self.db = db
self.accountKeyStore = accountKeyStore
self.localStorage = svrLocalStorage
self.pinHasher = pinHasher
self.storageServiceManager = storageServiceManager
self.tsAccountManager = tsAccountManager
self.tsConstants = tsConstants
@ -471,10 +440,7 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
let pinHash: SVR2PinHash
let encryptedMasterKey: Data
do {
pinHash = try connection.hashPin(
pin: pin,
wrapper: clientWrapper,
)
pinHash = try hashPin(pin, forConnection: connection.connection)
encryptedMasterKey = try pinHash.encryptMasterKey(masterKey)
} catch {
return .localEncryptionError
@ -723,7 +689,7 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
) async -> RestoreResult {
let pinHash: SVR2PinHash
do {
pinHash = try connection.hashPin(pin: pin, wrapper: clientWrapper)
pinHash = try hashPin(pin, forConnection: connection.connection)
} catch {
return .decryptionError
}
@ -1024,7 +990,7 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
/// we keep track of how many requests are going out, decrement when they finish,
/// and close the connection when there are none left.
private class WebsocketConnection {
private let connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>
let connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>
let mrEnclave: MrEnclave
init(connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>) {
@ -1050,18 +1016,17 @@ public class SecureValueRecovery2Impl: SecureValueRecovery {
func disconnect(isNormalClosure: Bool) {
connection.disconnect(code: isNormalClosure ? .normalClosure : nil)
}
}
func hashPin(
pin: String,
wrapper: SVR2ClientWrapper,
) throws -> SVR2PinHash {
let utf8NormalizedPin = Data(SVRUtil.normalizePin(pin).utf8)
return try wrapper.hashPin(
connection: connection,
utf8NormalizedPin: utf8NormalizedPin,
username: connection.auth.username,
)
}
func hashPin(
_ pin: String,
forConnection connection: SgxWebsocketConnection<SVR2WebsocketConfigurator>,
) throws -> SVR2PinHash {
return try pinHasher.hashPin(
normalizedPin: SVRUtil.normalizePin(pin),
username: connection.auth.username,
mrEnclave: connection.mrEnclave,
)
}
private struct CachedWebsocketConnection {

View File

@ -27,8 +27,6 @@ struct SVR2ConcurrencyTests {
mockConnection.mockAuth = RemoteAttestation.Auth(username: "username", password: "password")
mockConnectionFactory = MockSgxWebsocketConnectionFactory()
let mockClientWrapper = MockSVR2ClientWrapper()
let accountKeyStore = AccountKeyStore(
backupSettingsStore: BackupSettingsStore(),
)
@ -38,11 +36,11 @@ struct SVR2ConcurrencyTests {
appContext: SVR2.Mocks.AppContext(),
appReadiness: AppReadinessMock(),
appVersion: MockAppVerion(),
clientWrapper: mockClientWrapper,
connectionFactory: mockConnectionFactory,
credentialStorage: credentialStorage,
db: db,
accountKeyStore: accountKeyStore,
pinHasher: MockPinHasher(),
storageServiceManager: FakeStorageServiceManager(),
svrLocalStorage: localStorage,
tsAccountManager: MockTSAccountManager(),

View File

@ -45,11 +45,11 @@ class SecureValueRecovery2Tests: XCTestCase {
appContext: SVR2.Mocks.AppContext(),
appReadiness: AppReadinessMock(),
appVersion: MockAppVerion(),
clientWrapper: MockSVR2ClientWrapper(),
connectionFactory: mockConnectionFactory,
credentialStorage: credentialStorage,
db: db,
accountKeyStore: accountKeyStore,
pinHasher: MockPinHasher(),
storageServiceManager: FakeStorageServiceManager(),
svrLocalStorage: localStorage,
tsAccountManager: mockTSAccountManager,
@ -106,9 +106,8 @@ class SecureValueRecovery2Tests: XCTestCase {
case 0:
// First it should issue a backup to the new enclave.
XCTAssert(request.hasBackup)
// Test mock encruption just passes along the unmodified master key and pin.
XCTAssertEqual(request.backup.data, masterKey.rawData)
XCTAssertEqual(request.backup.pin, pin.data(using: .utf8))
XCTAssertEqual(request.backup.data.count, 48)
XCTAssertEqual(request.backup.pin.count, 32)
var backupResponse = SVR2Proto_BackupResponse()
backupResponse.status = .ok
@ -116,8 +115,7 @@ class SecureValueRecovery2Tests: XCTestCase {
case 1:
// Then an expose
XCTAssert(request.hasExpose)
// Test mock encruption just passes along the unmodified master key.
XCTAssertEqual(request.expose.data, masterKey.rawData)
XCTAssertEqual(request.expose.data.count, 48)
var exposeResponse = SVR2Proto_ExposeResponse()
exposeResponse.status = .ok
@ -211,9 +209,8 @@ class SecureValueRecovery2Tests: XCTestCase {
case 0:
// First it should issue a backup to the new enclave.
XCTAssert(request.hasBackup)
// Test mock encruption just passes along the unmodified master key and pin.
XCTAssertEqual(request.backup.data, masterKey.rawData)
XCTAssertEqual(request.backup.pin, pin.data(using: .utf8))
XCTAssertEqual(request.backup.data.count, 48)
XCTAssertEqual(request.backup.pin.count, 32)
var backupResponse = SVR2Proto_BackupResponse()
backupResponse.status = .ok
@ -221,8 +218,7 @@ class SecureValueRecovery2Tests: XCTestCase {
case 1:
// Then an expose
XCTAssert(request.hasExpose)
// Test mock encruption just passes along the unmodified master key.
XCTAssertEqual(request.expose.data, masterKey.rawData)
XCTAssertEqual(request.expose.data.count, 48)
var exposeResponse = SVR2Proto_ExposeResponse()
exposeResponse.status = .ok