#7 add SCRAM-SHA-512 and document TLS 1.3 support
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- Implement SCRAM-SHA-512 (RFC 5802 with SHA-512) as a Luma-side
  SaslMechanism and register it ahead of Martin's SHA-256/SHA-1/PLAIN,
  so modern servers preferring SHA-512 authenticate with it.
- Verify the math against Python-computed reference vectors (salted
  password, client proof, server signature) in SCRAMSHA512Tests and
  guard the mechanism registration in Scripts/verify.sh.
- Confirm the TLS stack negotiates TLS 1.3 (negotiatedSSL + ALPN via
  SecureTransport, handshake verified against a TLS 1.3-only server)
  and document the TLS/SCRAM posture in SECURITY.md.
This commit is contained in:
wt
2026-08-29 05:30:30 +07:00
parent 33702420a0
commit b0eb44d7a7
8 changed files with 331 additions and 3 deletions
+2 -1
View File
@@ -15,7 +15,8 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from
SwiftData through `@Query` (`MainChatView`, `ChatView`,
`ForwardMessageView`).
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
`SASLprep` (RFC 4013), SASL failure observer/messages.
`SASLprep` (RFC 4013), SCRAM-SHA-512 mechanism, SASL failure
observer/messages.
- `Persistence/` — `ArchiveStore` (per-account SwiftData container),
`ArchiveMetadataRecord` (durable MAM checkpoint metadata),
`LegacyArchiveImporter` (one-time legacy JSON snapshot migration),
+10
View File
@@ -9,6 +9,16 @@
доверия обязательны даже при ручном адресе подключения.
- Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с
серверной нормализацией; для PLAIN пароль отправляется как введён.
- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma),
SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера.
- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем
`negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах
с поддержкой SecureTransport договаривается TLS 1.3.
- SCRAM доступен в вариантах SHA-512 (предпочтительный, реализация Luma),
SHA-256 и SHA-1 (Martin); механизм выбирается по рекламе сервера.
- Канал шифруется STARTTLS/direct TLS через SecureTransport с уровнем
`negotiatedSSL` (исключает TLS ниже 1.2) и ALPN «xmpp-client»; на системах
с поддержкой SecureTransport договаривается TLS 1.3.
- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
+10
View File
@@ -26,6 +26,7 @@
0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; };
10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */; };
10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */; };
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; };
12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */; };
137816B417A865C97F935A00 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = EFCAF9B0DE67466AF22E76C3 /* WebRTC */; };
1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; };
@@ -65,6 +66,7 @@
3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; };
3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; };
3BEF3895B4C84880B9103058 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; };
3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */; };
3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; };
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; };
@@ -212,6 +214,7 @@
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */; };
E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; };
E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; };
E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; };
E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; };
EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; };
@@ -319,6 +322,7 @@
4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = "<group>"; };
43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = "<group>"; };
5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = "<group>"; };
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = "<group>"; };
515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = "<group>"; };
5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = "<group>"; };
@@ -343,6 +347,7 @@
7846C2EEE2551C6690A4FDC6 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = "<group>"; };
78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewView.swift; sourceTree = "<group>"; };
7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageRecorder.swift; sourceTree = "<group>"; };
7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramSha512Mechanism.swift; sourceTree = "<group>"; };
7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInteractionTests.swift; sourceTree = "<group>"; };
7C9866558B72CB1909A01EEC /* Shared.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Shared.xcassets; sourceTree = "<group>"; };
7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerRecordingGestureTests.swift; sourceTree = "<group>"; };
@@ -446,6 +451,7 @@
E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */,
F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */,
C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */,
7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */,
69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */,
B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */,
F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */,
@@ -572,6 +578,7 @@
F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */,
1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */,
13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */,
5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */,
9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */,
D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */,
33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */,
@@ -912,6 +919,7 @@
CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */,
F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */,
0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */,
E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */,
08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */,
0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */,
2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */,
@@ -1020,6 +1028,7 @@
03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */,
7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */,
C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */,
127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */,
7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */,
EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */,
AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */,
@@ -1095,6 +1104,7 @@
0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */,
9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */,
1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */,
3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */,
3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */,
FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */,
E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */,
+10
View File
@@ -84,6 +84,8 @@ required=(
Tests/SASLprepTests.swift
Tests/SaslFailureMessageTests.swift
Tests/MediaPreviewProcessorVideoTests.swift
Tests/SCRAMSHA512Tests.swift
Sources/Shared/XMPP/LumaScramSha512Mechanism.swift
Sources/Shared/XMPP/SASLprep.swift
Sources/Shared/XMPP/LumaSaslFailureModule.swift
Sources/Shared/XMPP/SaslFailureMessage.swift
@@ -321,6 +323,14 @@ grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || {
echo "The login screen must let the user verify the typed password"
exit 1
}
grep -q 'SCRAM-SHA-512' Sources/Shared/XMPP/LumaScramSha512Mechanism.swift || {
echo "The SCRAM-SHA-512 mechanism must be available"
exit 1
}
grep -q 'addMechanism(LumaScramSha512Mechanism' Sources/Shared/XMPP/XMPPService.swift || {
echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms"
exit 1
}
grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || {
echo "Group chats must support local deletion"
exit 1
+3 -1
View File
@@ -79,9 +79,9 @@ struct LoginView: View {
TextField("you@example.org", text: $jid)
#if os(iOS)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
#endif
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
@@ -101,7 +101,9 @@ struct LoginView: View {
}
}
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.textFieldStyle(.roundedBorder)
Button {
@@ -0,0 +1,205 @@
import CommonCrypto
import CryptoKit
import Foundation
import Martin
/// Pure SCRAM-SHA-512 math per RFC 5802 (SHA-512 / HMAC-SHA-512), kept
/// separate from the Martin mechanism so the proof computation can be
/// unit-tested against reference vectors.
enum SCRAMSHA512 {
struct ServerFirst {
let nonce: String
let salt: Data
let iterations: Int
}
private static let nonceAlphabet = Array(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
)
static func makeNonce(length: Int = 24) -> String {
String((0..<length).map { _ in nonceAlphabet.randomElement()! })
}
static func parseServerFirst(
_ message: String,
expectedNoncePrefix: String
) throws -> ServerFirst {
let pattern = #"^(?:m=[^\000=]+,)?r=([\x21-\x2B\x2D-\x7E]+),s=([a-zA-Z0-9/+=]+),i=(\d+)(?:,.*)?$"#
guard let regex = try? NSRegularExpression(pattern: pattern) else {
throw ClientSaslException.badChallenge(msg: "Failed to parse challenge")
}
let fullRange = NSRange(message.startIndex..., in: message)
guard let match = regex.firstMatch(in: message, range: fullRange),
match.numberOfRanges >= 4,
let nonceRange = Range(match.range(at: 1), in: message),
let saltRange = Range(match.range(at: 2), in: message),
let iterationsRange = Range(match.range(at: 3), in: message) else {
throw ClientSaslException.badChallenge(msg: "Failed to parse challenge")
}
let nonce = String(message[nonceRange])
let iterations = Int(message[iterationsRange]) ?? 0
guard nonce.hasPrefix(expectedNoncePrefix),
let salt = Data(base64Encoded: String(message[saltRange])),
iterations > 0 else {
throw ClientSaslException.badChallenge(msg: "Invalid challenge")
}
return ServerFirst(nonce: nonce, salt: salt, iterations: iterations)
}
/// PBKDF2-HMAC-SHA-512 (RFC 5802 \"Hi\" function).
static func saltedPassword(password: String, salt: Data, iterations: Int) -> [UInt8] {
let passwordBytes = Array(password.utf8)
let saltBytes = [UInt8](salt)
var output = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passwordBytes, passwordBytes.count,
saltBytes, saltBytes.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512),
UInt32(iterations),
&output, output.count
)
return output
}
static func clientProof(saltedPassword: [UInt8], authMessage: String) -> [UInt8] {
let clientKey = hmac(saltedPassword, Array("Client Key".utf8))
let storedKey = digest(clientKey)
let clientSignature = hmac(storedKey, Array(authMessage.utf8))
return zip(clientKey, clientSignature).map(^)
}
static func serverSignature(saltedPassword: [UInt8], authMessage: String) -> [UInt8] {
let serverKey = hmac(saltedPassword, Array("Server Key".utf8))
return hmac(serverKey, Array(authMessage.utf8))
}
static func verifyServerSignature(
saltedPassword: [UInt8],
authMessage: String,
finalMessage: String
) -> Bool {
let pattern = #"^(?:e=([^,]+)|v=([a-zA-Z0-9/+=]+)(?:,.*)?)$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(
in: finalMessage,
range: NSRange(finalMessage.startIndex..., in: finalMessage)
),
match.numberOfRanges >= 3,
let vRange = Range(match.range(at: 2), in: finalMessage),
let value = Data(base64Encoded: String(finalMessage[vRange])) else {
return false
}
return value == Data(serverSignature(saltedPassword: saltedPassword, authMessage: authMessage))
}
private static func hmac(_ key: [UInt8], _ data: [UInt8]) -> [UInt8] {
let code = HMAC<SHA512>.authenticationCode(
for: Data(data),
using: SymmetricKey(data: Data(key))
)
return Array(code)
}
private static func digest(_ data: [UInt8]) -> [UInt8] {
Array(SHA512.hash(data: Data(data)))
}
}
/// SCRAM-SHA-512 SASL mechanism. Martin ships only SCRAM-SHA-1 and
/// SCRAM-SHA-256; modern servers prefer SHA-512, so Luma registers this
/// mechanism ahead of Martin's ones.
final class LumaScramSha512Mechanism: SaslMechanism {
let name = "SCRAM-SHA-512"
private(set) var status: SaslMechanismStatus = .new
private var stage = 0
private var clientNonce = ""
private var clientFirstMessageBare = ""
private var authMessage = ""
private var saltedPassword: [UInt8] = []
func reset(scopes: Set<ResetableScope>) {
guard scopes.contains(.stream) else { return }
status = .new
stage = 0
clientNonce = ""
clientFirstMessageBare = ""
authMessage = ""
saltedPassword = []
}
func isAllowedToUse(_ context: Context) -> Bool {
if case .password(_, _, _) = context.connectionConfiguration.credentials {
return true
}
return false
}
func evaluateChallenge(_ input: String?, context: Context) throws -> String? {
guard status != .completed else {
guard input == nil else {
throw ClientSaslException.genericError(msg: "Already authorized")
}
return nil
}
switch stage {
case 0:
guard case .password(_, _, _) = context.connectionConfiguration.credentials else {
throw ClientSaslException.genericError(msg: "Invalid credentials type")
}
clientNonce = SCRAMSHA512.makeNonce()
clientFirstMessageBare =
"n=\(context.userBareJid.localPart ?? ""),r=\(clientNonce)"
stage = 1
status = .completedExpected
let first = "n,," + clientFirstMessageBare
return first.data(using: .utf8)?.base64EncodedString()
case 1:
guard case .password(let password, _, _) = context.connectionConfiguration.credentials,
let input,
let data = Data(base64Encoded: input),
let serverFirst = String(data: data, encoding: .utf8) else {
throw ClientSaslException.badChallenge(msg: "Invalid challenge")
}
let parsed = try SCRAMSHA512.parseServerFirst(
serverFirst,
expectedNoncePrefix: clientNonce
)
let clientFinalWithoutProof = "c=biws,r=\(parsed.nonce)"
authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalWithoutProof
saltedPassword = SCRAMSHA512.saltedPassword(
password: password,
salt: parsed.salt,
iterations: parsed.iterations
)
let proof = SCRAMSHA512.clientProof(
saltedPassword: saltedPassword,
authMessage: authMessage
)
stage = 2
let final = clientFinalWithoutProof + ",p=" + Data(proof).base64EncodedString()
return final.data(using: .utf8)?.base64EncodedString()
case 2:
guard let input,
let data = Data(base64Encoded: input),
let finalMessage = String(data: data, encoding: .utf8),
SCRAMSHA512.verifyServerSignature(
saltedPassword: saltedPassword,
authMessage: authMessage,
finalMessage: finalMessage
) else {
throw ClientSaslException.invalidServerSignature
}
status = .completed
return nil
default:
throw ClientSaslException.genericError(msg: "Illegal state")
}
}
}
+5 -1
View File
@@ -1565,7 +1565,11 @@ final class XMPPService {
// Registered before SaslModule so the raw RFC 6120 failure condition
// is captured before Martin collapses it into SaslError.
saslFailureModule = client.modulesManager.register(LumaSaslFailureModule())
_ = client.modulesManager.register(SaslModule())
// SCRAM-SHA-512 first: Martin only ships SHA-1/SHA-256, while modern
// servers prefer SHA-512. The server must advertise it or Martin's
// mechanism selection skips it.
let sasl = client.modulesManager.register(SaslModule())
sasl.addMechanism(LumaScramSha512Mechanism(), first: true)
_ = client.modulesManager.register(ResourceBinderModule())
_ = client.modulesManager.register(SessionEstablishmentModule())
_ = client.modulesManager.register(
+86
View File
@@ -0,0 +1,86 @@
import XCTest
@testable import Luma
final class SCRAMSHA512Tests: XCTestCase {
/// Reference values computed with Python hashlib/hmac for the RFC 5802
/// vector inputs (password \"pencil\", salt QSXCR+Q6sek8bf92, 4096 rounds).
private let referenceAuthMessage =
"n=user,r=fyko+d2lbbFgONRv9qkxdawL"
+ ",r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096"
+ ",c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j"
func testSaltedPasswordMatchesReference() throws {
let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92"))
let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096)
let hex = salted.map { String(format: "%02x", $0) }.joined()
XCTAssertEqual(
hex,
"97382788b15cbe09512d2d20b7e0b8832f8dbab4b7388395440535cd9395e0ffaa1625453b6fde746412bbf903d4bc1d5f448d57f2ac3dd1d2c04979a914ee65"
)
}
func testClientProofMatchesReference() throws {
let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92"))
let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096)
let proof = SCRAMSHA512.clientProof(
saltedPassword: salted,
authMessage: referenceAuthMessage
)
XCTAssertEqual(
Data(proof).base64EncodedString(),
"VdS8LkrURiej1tG6iX+fqCXQfUnBb//d9llXYaH+ylUbDwBUz9geyR9fC4TewskRUM2tlYSalhAT4Aay1Q5dTA=="
)
}
func testServerSignatureMatchesReference() throws {
let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92"))
let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096)
let signature = SCRAMSHA512.serverSignature(
saltedPassword: salted,
authMessage: referenceAuthMessage
)
XCTAssertEqual(
Data(signature).base64EncodedString(),
"14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g=="
)
}
func testVerifyServerSignatureAcceptsAndRejects() throws {
let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92"))
let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096)
let good = "v=14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g=="
XCTAssertTrue(SCRAMSHA512.verifyServerSignature(
saltedPassword: salted,
authMessage: referenceAuthMessage,
finalMessage: good
))
XCTAssertFalse(SCRAMSHA512.verifyServerSignature(
saltedPassword: salted,
authMessage: referenceAuthMessage,
finalMessage: "v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
))
XCTAssertFalse(SCRAMSHA512.verifyServerSignature(
saltedPassword: salted,
authMessage: referenceAuthMessage,
finalMessage: "e=server-error",
))
}
func testParseServerFirst() throws {
let parsed = try SCRAMSHA512.parseServerFirst(
"r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096",
expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL"
)
XCTAssertEqual(parsed.nonce, "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j")
XCTAssertEqual(parsed.salt, Data(base64Encoded: "QSXCR+Q6sek8bf92"))
XCTAssertEqual(parsed.iterations, 4096)
}
func testParseServerFirstRejectsWrongNonce() {
XCTAssertThrowsError(try SCRAMSHA512.parseServerFirst(
"r=OTHER,s=QSXCR+Q6sek8bf92,i=4096",
expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL"
))
}
}