Init
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "PortalKit",
|
||||
platforms: [
|
||||
.macOS(.v12)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "PortalKit",
|
||||
targets: ["PortalKit"]
|
||||
),
|
||||
.executable(
|
||||
name: "portalkit-cli",
|
||||
targets: ["portalkit-cli"]
|
||||
),
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.target(
|
||||
name: "PortalKit",
|
||||
dependencies: [],
|
||||
path: "Sources/PortalKit"
|
||||
),
|
||||
.executableTarget(
|
||||
name: "portalkit-cli",
|
||||
dependencies: ["PortalKit"],
|
||||
path: "Sources/portalkit-cli"
|
||||
),
|
||||
.testTarget(
|
||||
name: "PortalKitTests",
|
||||
dependencies: ["PortalKit"],
|
||||
path: "Tests/PortalKitTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// CredentialStorage.swift
|
||||
// PortalKit
|
||||
//
|
||||
// Abstraction for persisting pairing tokens and pinned TLS fingerprints.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol CredentialStorage: Sendable {
|
||||
func getAuthToken() -> String?
|
||||
func getPinnedCertSha256() -> String?
|
||||
func save(token: String, certSha256: String)
|
||||
func clear()
|
||||
}
|
||||
|
||||
/// Thread-safe in-memory credential storage for unit testing and ephemeral sessions.
|
||||
public final class InMemoryCredentialStorage: CredentialStorage, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var token: String?
|
||||
private var certSha256: String?
|
||||
|
||||
public init(token: String? = nil, certSha256: String? = nil) {
|
||||
self.token = token
|
||||
self.certSha256 = certSha256?.lowercased()
|
||||
}
|
||||
|
||||
public func getAuthToken() -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return token
|
||||
}
|
||||
|
||||
public func getPinnedCertSha256() -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return certSha256
|
||||
}
|
||||
|
||||
public func save(token: String, certSha256: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.token = token
|
||||
self.certSha256 = certSha256.lowercased()
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
self.token = nil
|
||||
self.certSha256 = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-file-backed credential storage (default for CLI: ~/.portalkit/credentials.json).
|
||||
public final class FileCredentialStorage: CredentialStorage, @unchecked Sendable {
|
||||
private let fileURL: URL
|
||||
private let lock = NSLock()
|
||||
|
||||
private struct CredentialsRecord: Codable {
|
||||
var token: String?
|
||||
var certSha256: String?
|
||||
}
|
||||
|
||||
public init(fileURL: URL? = nil) {
|
||||
if let url = fileURL {
|
||||
self.fileURL = url
|
||||
} else {
|
||||
let homeDir = FileManager.default.homeDirectoryForCurrentUser
|
||||
let portalKitDir = homeDir.appendingPathComponent(".portalkit", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: portalKitDir, withIntermediateDirectories: true)
|
||||
self.fileURL = portalKitDir.appendingPathComponent("credentials.json")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRecord() -> CredentialsRecord {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let record = try? JSONDecoder().decode(CredentialsRecord.self, from: data) else {
|
||||
return CredentialsRecord(token: nil, certSha256: nil)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
private func persist(record: CredentialsRecord) {
|
||||
let parentDir = fileURL.deletingLastPathComponent()
|
||||
try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
|
||||
if let data = try? JSONEncoder().encode(record) {
|
||||
try? data.write(to: fileURL, options: .atomic)
|
||||
// Ensure permissions are 0600 (owner read/write only)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
|
||||
}
|
||||
}
|
||||
|
||||
public func getAuthToken() -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return loadRecord().token
|
||||
}
|
||||
|
||||
public func getPinnedCertSha256() -> String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return loadRecord().certSha256
|
||||
}
|
||||
|
||||
public func save(token: String, certSha256: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let record = CredentialsRecord(token: token, certSha256: certSha256.lowercased())
|
||||
persist(record: record)
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// KeychainCredentialStorage.swift
|
||||
// PortalKit
|
||||
//
|
||||
// macOS Keychain credential storage with Data Protection Keychain and fallback.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import os.log
|
||||
|
||||
public final class KeychainCredentialStorage: CredentialStorage, @unchecked Sendable {
|
||||
private let log = Logger(subsystem: "com.kovtash.portalkit", category: "keychain")
|
||||
|
||||
public let service: String
|
||||
public let accessGroup: String?
|
||||
public let suiteName: String?
|
||||
public let tokenKey: String
|
||||
public let certKey: String
|
||||
|
||||
public init(
|
||||
service: String = "com.kovtash.portalcam.auth",
|
||||
accessGroup: String? = "ENT9X9U544.com.kovtash.portalcam",
|
||||
suiteName: String? = "ENT9X9U544.com.kovtash.portalcam",
|
||||
tokenKey: String = "portalAuthToken",
|
||||
certKey: String = "portalPinnedCertSha256"
|
||||
) {
|
||||
self.service = service
|
||||
self.accessGroup = accessGroup
|
||||
self.suiteName = suiteName
|
||||
self.tokenKey = tokenKey
|
||||
self.certKey = certKey
|
||||
}
|
||||
|
||||
public func getAuthToken() -> String? {
|
||||
readItem(account: tokenKey)
|
||||
}
|
||||
|
||||
public func getPinnedCertSha256() -> String? {
|
||||
readItem(account: certKey)
|
||||
}
|
||||
|
||||
public func save(token: String, certSha256: String) {
|
||||
saveItem(account: tokenKey, value: token)
|
||||
saveItem(account: certKey, value: certSha256.lowercased())
|
||||
}
|
||||
|
||||
public func clear() {
|
||||
deleteItem(account: tokenKey)
|
||||
deleteItem(account: certKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Keychain Helpers
|
||||
|
||||
private func saveItem(account: String, value: String) {
|
||||
let data = Data(value.utf8)
|
||||
|
||||
// Delete from legacy file-based keychain if present
|
||||
let legacyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
_ = SecItemDelete(legacyQuery as CFDictionary)
|
||||
|
||||
// Delete existing item in Data Protection Keychain
|
||||
var dpDeleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
if let ag = accessGroup {
|
||||
dpDeleteQuery[kSecAttrAccessGroup as String] = ag
|
||||
}
|
||||
_ = SecItemDelete(dpDeleteQuery as CFDictionary)
|
||||
|
||||
// Try adding with accessGroup if specified
|
||||
var addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
if let ag = accessGroup {
|
||||
addQuery[kSecAttrAccessGroup as String] = ag
|
||||
}
|
||||
|
||||
var status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
|
||||
// If access group fails due to missing entitlements (e.g. running from CLI or test runner),
|
||||
// retry without access group.
|
||||
if status == errSecMissingEntitlement && accessGroup != nil {
|
||||
addQuery.removeValue(forKey: kSecAttrAccessGroup as String)
|
||||
status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
}
|
||||
|
||||
if status != errSecSuccess {
|
||||
log.error("PortalKit: Keychain save failed for \(account, privacy: .public): \(status)")
|
||||
}
|
||||
|
||||
// Shared App Group fallback if suiteName provided
|
||||
if let suite = suiteName {
|
||||
UserDefaults(suiteName: suite)?.set(value, forKey: account)
|
||||
}
|
||||
}
|
||||
|
||||
private func readItem(account: String) -> String? {
|
||||
// 1. Try Data Protection Keychain with accessGroup
|
||||
var dpQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
if let ag = accessGroup {
|
||||
dpQuery[kSecAttrAccessGroup as String] = ag
|
||||
}
|
||||
|
||||
var item: CFTypeRef?
|
||||
var status = SecItemCopyMatching(dpQuery as CFDictionary, &item)
|
||||
|
||||
// If failed with missing entitlement, retry without access group
|
||||
if status == errSecMissingEntitlement && accessGroup != nil {
|
||||
dpQuery.removeValue(forKey: kSecAttrAccessGroup as String)
|
||||
status = SecItemCopyMatching(dpQuery as CFDictionary, &item)
|
||||
}
|
||||
|
||||
if status == errSecSuccess, let data = item as? Data,
|
||||
let value = String(data: data, encoding: .utf8) {
|
||||
return value
|
||||
}
|
||||
|
||||
// 2. Fallback to legacy file-based keychain
|
||||
let legacyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var legacyItem: CFTypeRef?
|
||||
let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &legacyItem)
|
||||
if legacyStatus == errSecSuccess, let data = legacyItem as? Data,
|
||||
let value = String(data: data, encoding: .utf8) {
|
||||
return value
|
||||
}
|
||||
|
||||
// 3. Fallback to App Group UserDefaults
|
||||
if let suite = suiteName, let value = UserDefaults(suiteName: suite)?.string(forKey: account) {
|
||||
return value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func deleteItem(account: String) {
|
||||
let legacyQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
SecItemDelete(legacyQuery as CFDictionary)
|
||||
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true
|
||||
]
|
||||
if let ag = accessGroup {
|
||||
query[kSecAttrAccessGroup as String] = ag
|
||||
}
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
if accessGroup != nil {
|
||||
query.removeValue(forKey: kSecAttrAccessGroup as String)
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
if let suite = suiteName {
|
||||
UserDefaults(suiteName: suite)?.removeObject(forKey: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// PortalAuth.swift
|
||||
// PortalKit
|
||||
//
|
||||
// Static convenience facade around CredentialStorage matching the PortalCam interface.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PortalAuth {
|
||||
public static let suite = "ENT9X9U544.com.kovtash.portalcam"
|
||||
public static let accessGroup = "ENT9X9U544.com.kovtash.portalcam"
|
||||
public static let service = "com.kovtash.portalcam.auth"
|
||||
public static let tokenKey = "portalAuthToken"
|
||||
public static let certKey = "portalPinnedCertSha256"
|
||||
|
||||
private static let lock = NSLock()
|
||||
private static var _defaultStorage: CredentialStorage = KeychainCredentialStorage(
|
||||
service: service,
|
||||
accessGroup: accessGroup,
|
||||
suiteName: suite,
|
||||
tokenKey: tokenKey,
|
||||
certKey: certKey
|
||||
)
|
||||
|
||||
public static var defaultStorage: CredentialStorage {
|
||||
get {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _defaultStorage
|
||||
}
|
||||
set {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
_defaultStorage = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the auth token from the default credential storage
|
||||
public static var token: String? {
|
||||
defaultStorage.getAuthToken()
|
||||
}
|
||||
|
||||
/// Reads the pinned certificate SHA-256 fingerprint from the default credential storage
|
||||
public static var pinnedCertSha256: String? {
|
||||
defaultStorage.getPinnedCertSha256()
|
||||
}
|
||||
|
||||
/// Saves both auth token and pinned certificate fingerprint
|
||||
public static func save(token: String, certSha256: String) {
|
||||
defaultStorage.save(token: token, certSha256: certSha256)
|
||||
}
|
||||
|
||||
/// Removes both auth token and pinned certificate
|
||||
public static func clear() {
|
||||
defaultStorage.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//
|
||||
// Addition.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Addition
|
||||
|
||||
/// Add `word` to this integer in place.
|
||||
/// `word` is shifted `shift` words to the left before being added.
|
||||
///
|
||||
/// - Complexity: O(max(count, shift))
|
||||
internal mutating func addWord(_ word: Word, shiftedBy shift: Int = 0) {
|
||||
precondition(shift >= 0)
|
||||
var carry = word
|
||||
var i = shift
|
||||
while carry > 0 {
|
||||
let (d, c) = self[i].addingReportingOverflow(carry)
|
||||
self[i] = d
|
||||
carry = (c ? 1 : 0)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the digit `d` to this integer and return the result.
|
||||
/// `d` is shifted `shift` words to the left before being added.
|
||||
///
|
||||
/// - Complexity: O(max(count, shift))
|
||||
internal func addingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt {
|
||||
var r = self
|
||||
r.addWord(word, shiftedBy: shift)
|
||||
return r
|
||||
}
|
||||
|
||||
/// Add `b` to this integer in place.
|
||||
/// `b` is shifted `shift` words to the left before being added.
|
||||
///
|
||||
/// - Complexity: O(max(count, b.count + shift))
|
||||
internal mutating func add(_ b: BigUInt, shiftedBy shift: Int = 0) {
|
||||
precondition(shift >= 0)
|
||||
var carry = false
|
||||
var bi = 0
|
||||
let bc = b.count
|
||||
while bi < bc || carry {
|
||||
let ai = shift + bi
|
||||
let (d, c) = self[ai].addingReportingOverflow(b[bi])
|
||||
if carry {
|
||||
let (d2, c2) = d.addingReportingOverflow(1)
|
||||
self[ai] = d2
|
||||
carry = c || c2
|
||||
}
|
||||
else {
|
||||
self[ai] = d
|
||||
carry = c
|
||||
}
|
||||
bi += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `b` to this integer and return the result.
|
||||
/// `b` is shifted `shift` words to the left before being added.
|
||||
///
|
||||
/// - Complexity: O(max(count, b.count + shift))
|
||||
internal func adding(_ b: BigUInt, shiftedBy shift: Int = 0) -> BigUInt {
|
||||
var r = self
|
||||
r.add(b, shiftedBy: shift)
|
||||
return r
|
||||
}
|
||||
|
||||
/// Increment this integer by one. If `shift` is non-zero, it selects
|
||||
/// the word that is to be incremented.
|
||||
///
|
||||
/// - Complexity: O(count + shift)
|
||||
internal mutating func increment(shiftedBy shift: Int = 0) {
|
||||
self.addWord(1, shiftedBy: shift)
|
||||
}
|
||||
|
||||
/// Add `a` and `b` together and return the result.
|
||||
///
|
||||
/// - Complexity: O(max(a.count, b.count))
|
||||
public static func +(a: BigUInt, b: BigUInt) -> BigUInt {
|
||||
return a.adding(b)
|
||||
}
|
||||
|
||||
/// Add `a` and `b` together, and store the sum in `a`.
|
||||
///
|
||||
/// - Complexity: O(max(a.count, b.count))
|
||||
public static func +=(a: inout BigUInt, b: BigUInt) {
|
||||
a.add(b, shiftedBy: 0)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Add `a` to `b` and return the result.
|
||||
public static func +(a: BigInt, b: BigInt) -> BigInt {
|
||||
switch (a.sign, b.sign) {
|
||||
case (.plus, .plus):
|
||||
return BigInt(sign: .plus, magnitude: a.magnitude + b.magnitude)
|
||||
case (.minus, .minus):
|
||||
return BigInt(sign: .minus, magnitude: a.magnitude + b.magnitude)
|
||||
case (.plus, .minus):
|
||||
if a.magnitude >= b.magnitude {
|
||||
return BigInt(sign: .plus, magnitude: a.magnitude - b.magnitude)
|
||||
}
|
||||
else {
|
||||
return BigInt(sign: .minus, magnitude: b.magnitude - a.magnitude)
|
||||
}
|
||||
case (.minus, .plus):
|
||||
if b.magnitude >= a.magnitude {
|
||||
return BigInt(sign: .plus, magnitude: b.magnitude - a.magnitude)
|
||||
}
|
||||
else {
|
||||
return BigInt(sign: .minus, magnitude: a.magnitude - b.magnitude)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `b` to `a` in place.
|
||||
public static func +=(a: inout BigInt, b: BigInt) {
|
||||
a = a + b
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// BigInt.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2015-12-27.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
//MARK: BigInt
|
||||
|
||||
/// An arbitary precision signed integer type, also known as a "big integer".
|
||||
///
|
||||
/// Operations on big integers never overflow, but they might take a long time to execute.
|
||||
/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers.
|
||||
///
|
||||
/// This particular big integer type uses base-2^64 digits to represent integers.
|
||||
///
|
||||
/// `BigInt` is essentially a tiny wrapper that extends `BigUInt` with a sign bit and provides signed integer
|
||||
/// operations. Both the underlying absolute value and the negative/positive flag are available as read-write
|
||||
/// properties.
|
||||
///
|
||||
/// Not all algorithms of `BigUInt` are available for `BigInt` values; for example, there is no square root or
|
||||
/// primality test for signed integers. When you need to call one of these, just extract the absolute value:
|
||||
///
|
||||
/// ```Swift
|
||||
/// BigInt(255).magnitude.isPrime() // Returns false
|
||||
/// ```
|
||||
///
|
||||
public struct BigInt: SignedInteger, Sendable {
|
||||
public enum Sign: Sendable {
|
||||
case plus
|
||||
case minus
|
||||
}
|
||||
|
||||
public typealias Magnitude = BigUInt
|
||||
|
||||
/// The type representing a digit in `BigInt`'s underlying number system.
|
||||
public typealias Word = BigUInt.Word
|
||||
|
||||
public static var isSigned: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The absolute value of this integer.
|
||||
public var magnitude: BigUInt
|
||||
|
||||
/// True iff the value of this integer is negative.
|
||||
public var sign: Sign
|
||||
|
||||
/// Initializes a new big integer with the provided absolute number and sign flag.
|
||||
public init(sign: Sign, magnitude: BigUInt) {
|
||||
self.sign = (magnitude.isZero ? .plus : sign)
|
||||
self.magnitude = magnitude
|
||||
}
|
||||
|
||||
/// Return true iff this integer is zero.
|
||||
///
|
||||
/// - Complexity: O(1)
|
||||
public var isZero: Bool {
|
||||
return magnitude.isZero
|
||||
}
|
||||
|
||||
/// Returns `-1` if this value is negative and `1` if it’s positive; otherwise, `0`.
|
||||
///
|
||||
/// - Returns: The sign of this number, expressed as an integer of the same type.
|
||||
public func signum() -> BigInt {
|
||||
switch sign {
|
||||
case .plus:
|
||||
return isZero ? 0 : 1
|
||||
case .minus:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//
|
||||
// BigUInt.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2015-12-26.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
/// An arbitary precision unsigned integer type, also known as a "big integer".
|
||||
///
|
||||
/// Operations on big integers never overflow, but they may take a long time to execute.
|
||||
/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers.
|
||||
///
|
||||
/// This particular big integer type uses base-2^64 digits to represent integers; you can think of it as a wrapper
|
||||
/// around `Array<UInt64>`. (In fact, `BigUInt` only uses an array if there are more than two digits.)
|
||||
public struct BigUInt: UnsignedInteger, Sendable {
|
||||
/// The type representing a digit in `BigUInt`'s underlying number system.
|
||||
public typealias Word = UInt
|
||||
|
||||
/// The storage variants of a `BigUInt`.
|
||||
enum Kind {
|
||||
/// Value consists of the two specified words (low and high). Either or both words may be zero.
|
||||
case inline(Word, Word)
|
||||
/// Words are stored in a slice of the storage array.
|
||||
case slice(from: Int, to: Int)
|
||||
/// Words are stored in the storage array.
|
||||
case array
|
||||
}
|
||||
|
||||
fileprivate(set) var kind: Kind // Internal for testing only
|
||||
fileprivate(set) var storage: [Word] // Internal for testing only; stored separately to prevent COW copies
|
||||
|
||||
/// Initializes a new BigUInt with value 0.
|
||||
public init() {
|
||||
self.kind = .inline(0, 0)
|
||||
self.storage = []
|
||||
}
|
||||
|
||||
internal init(word: Word) {
|
||||
self.kind = .inline(word, 0)
|
||||
self.storage = []
|
||||
}
|
||||
|
||||
internal init(low: Word, high: Word) {
|
||||
self.kind = .inline(low, high)
|
||||
self.storage = []
|
||||
}
|
||||
|
||||
/// Initializes a new BigUInt with the specified digits. The digits are ordered from least to most significant.
|
||||
public init(words: [Word]) {
|
||||
self.kind = .array
|
||||
self.storage = words
|
||||
normalize()
|
||||
}
|
||||
|
||||
internal init(words: [Word], from startIndex: Int, to endIndex: Int) {
|
||||
self.kind = .slice(from: startIndex, to: endIndex)
|
||||
self.storage = words
|
||||
normalize()
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
public static var isSigned: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Return true iff this integer is zero.
|
||||
///
|
||||
/// - Complexity: O(1)
|
||||
public var isZero: Bool {
|
||||
switch kind {
|
||||
case .inline(0, 0): return true
|
||||
case .array: return storage.isEmpty
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `1` if this value is, positive; otherwise, `0`.
|
||||
///
|
||||
/// - Returns: The sign of this number, expressed as an integer of the same type.
|
||||
public func signum() -> BigUInt {
|
||||
return isZero ? 0 : 1
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
mutating func ensureArray() {
|
||||
switch kind {
|
||||
case let .inline(w0, w1):
|
||||
kind = .array
|
||||
storage = w1 != 0 ? [w0, w1]
|
||||
: w0 != 0 ? [w0]
|
||||
: []
|
||||
case let .slice(from: start, to: end):
|
||||
kind = .array
|
||||
storage = Array(storage[start ..< end])
|
||||
case .array:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var capacity: Int {
|
||||
guard case .array = kind else { return 0 }
|
||||
return storage.capacity
|
||||
}
|
||||
|
||||
mutating func reserveCapacity(_ minimumCapacity: Int) {
|
||||
switch kind {
|
||||
case let .inline(w0, w1):
|
||||
kind = .array
|
||||
storage.reserveCapacity(minimumCapacity)
|
||||
if w1 != 0 {
|
||||
storage.append(w0)
|
||||
storage.append(w1)
|
||||
}
|
||||
else if w0 != 0 {
|
||||
storage.append(w0)
|
||||
}
|
||||
case let .slice(from: start, to: end):
|
||||
kind = .array
|
||||
var words: [Word] = []
|
||||
words.reserveCapacity(Swift.max(end - start, minimumCapacity))
|
||||
words.append(contentsOf: storage[start ..< end])
|
||||
storage = words
|
||||
case .array:
|
||||
storage.reserveCapacity(minimumCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets rid of leading zero digits in the digit array and converts slices into inline digits when possible.
|
||||
internal mutating func normalize() {
|
||||
switch kind {
|
||||
case .slice(from: let start, to: var end):
|
||||
assert(start >= 0 && end <= storage.count && start <= end)
|
||||
while start < end, storage[end - 1] == 0 {
|
||||
end -= 1
|
||||
}
|
||||
switch end - start {
|
||||
case 0:
|
||||
kind = .inline(0, 0)
|
||||
storage = []
|
||||
case 1:
|
||||
kind = .inline(storage[start], 0)
|
||||
storage = []
|
||||
case 2:
|
||||
kind = .inline(storage[start], storage[start + 1])
|
||||
storage = []
|
||||
case storage.count:
|
||||
assert(start == 0)
|
||||
kind = .array
|
||||
default:
|
||||
kind = .slice(from: start, to: end)
|
||||
}
|
||||
case .array where storage.last == 0:
|
||||
while storage.last == 0 {
|
||||
storage.removeLast()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Set this integer to 0 without releasing allocated storage capacity (if any).
|
||||
mutating func clear() {
|
||||
self.load(0)
|
||||
}
|
||||
|
||||
/// Set this integer to `value` by copying its digits without releasing allocated storage capacity (if any).
|
||||
mutating func load(_ value: BigUInt) {
|
||||
switch kind {
|
||||
case .inline, .slice:
|
||||
self = value
|
||||
case .array:
|
||||
self.storage.removeAll(keepingCapacity: true)
|
||||
self.storage.append(contentsOf: value.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Collection-like members
|
||||
|
||||
/// The number of digits in this integer, excluding leading zero digits.
|
||||
var count: Int {
|
||||
switch kind {
|
||||
case let .inline(w0, w1):
|
||||
return w1 != 0 ? 2
|
||||
: w0 != 0 ? 1
|
||||
: 0
|
||||
case let .slice(from: start, to: end):
|
||||
return end - start
|
||||
case .array:
|
||||
return storage.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Get or set a digit at a given index.
|
||||
///
|
||||
/// - Note: Unlike a normal collection, it is OK for the index to be greater than or equal to `endIndex`.
|
||||
/// The subscripting getter returns zero for indexes beyond the most significant digit.
|
||||
/// Setting these extended digits automatically appends new elements to the underlying digit array.
|
||||
/// - Requires: index >= 0
|
||||
/// - Complexity: The getter is O(1). The setter is O(1) if the conditions below are true; otherwise it's O(count).
|
||||
/// - The integer's storage is not shared with another integer
|
||||
/// - The integer wasn't created as a slice of another integer
|
||||
/// - `index < count`
|
||||
subscript(_ index: Int) -> Word {
|
||||
get {
|
||||
precondition(index >= 0)
|
||||
switch (kind, index) {
|
||||
case (.inline(let w0, _), 0): return w0
|
||||
case (.inline(_, let w1), 1): return w1
|
||||
case (.slice(from: let start, to: let end), _) where index < end - start:
|
||||
return storage[start + index]
|
||||
case (.array, _) where index < storage.count:
|
||||
return storage[index]
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
set(word) {
|
||||
precondition(index >= 0)
|
||||
switch (kind, index) {
|
||||
case let (.inline(_, w1), 0):
|
||||
kind = .inline(word, w1)
|
||||
case let (.inline(w0, _), 1):
|
||||
kind = .inline(w0, word)
|
||||
case let (.slice(from: start, to: end), _) where index < end - start:
|
||||
replace(at: index, with: word)
|
||||
case (.array, _) where index < storage.count:
|
||||
replace(at: index, with: word)
|
||||
default:
|
||||
extend(at: index, with: word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func replace(at index: Int, with word: Word) {
|
||||
ensureArray()
|
||||
precondition(index < storage.count)
|
||||
storage[index] = word
|
||||
if word == 0, index == storage.count - 1 {
|
||||
normalize()
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func extend(at index: Int, with word: Word) {
|
||||
guard word != 0 else { return }
|
||||
reserveCapacity(index + 1)
|
||||
precondition(index >= storage.count)
|
||||
storage.append(contentsOf: repeatElement(0, count: index - storage.count))
|
||||
storage.append(word)
|
||||
}
|
||||
|
||||
/// Returns an integer built from the digits of this integer in the given range.
|
||||
internal func extract(_ bounds: Range<Int>) -> BigUInt {
|
||||
switch kind {
|
||||
case let .inline(w0, w1):
|
||||
let bounds = bounds.clamped(to: 0 ..< 2)
|
||||
if bounds == 0 ..< 2 {
|
||||
return BigUInt(low: w0, high: w1)
|
||||
}
|
||||
else if bounds == 0 ..< 1 {
|
||||
return BigUInt(word: w0)
|
||||
}
|
||||
else if bounds == 1 ..< 2 {
|
||||
return BigUInt(word: w1)
|
||||
}
|
||||
else {
|
||||
return BigUInt()
|
||||
}
|
||||
case let .slice(from: start, to: end):
|
||||
let s = Swift.min(end, start + Swift.max(bounds.lowerBound, 0))
|
||||
let e = Swift.max(s, (bounds.upperBound > end - start ? end : start + bounds.upperBound))
|
||||
return BigUInt(words: storage, from: s, to: e)
|
||||
case .array:
|
||||
let b = bounds.clamped(to: storage.startIndex ..< storage.endIndex)
|
||||
return BigUInt(words: storage, from: b.lowerBound, to: b.upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
internal func extract<Bounds: RangeExpression>(_ bounds: Bounds) -> BigUInt where Bounds.Bound == Int {
|
||||
return self.extract(bounds.relative(to: 0 ..< Int.max))
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
internal mutating func shiftRight(byWords amount: Int) {
|
||||
assert(amount >= 0)
|
||||
guard amount > 0 else { return }
|
||||
switch kind {
|
||||
case let .inline(_, w1) where amount == 1:
|
||||
kind = .inline(w1, 0)
|
||||
case .inline(_, _):
|
||||
kind = .inline(0, 0)
|
||||
case let .slice(from: start, to: end):
|
||||
let s = start + amount
|
||||
if s >= end {
|
||||
kind = .inline(0, 0)
|
||||
}
|
||||
else {
|
||||
kind = .slice(from: s, to: end)
|
||||
normalize()
|
||||
}
|
||||
case .array:
|
||||
if amount >= storage.count {
|
||||
storage.removeAll(keepingCapacity: true)
|
||||
}
|
||||
else {
|
||||
storage.removeFirst(amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal mutating func shiftLeft(byWords amount: Int) {
|
||||
assert(amount >= 0)
|
||||
guard amount > 0 else { return }
|
||||
guard !isZero else { return }
|
||||
switch kind {
|
||||
case let .inline(w0, 0) where amount == 1:
|
||||
kind = .inline(0, w0)
|
||||
case let .inline(w0, w1):
|
||||
let c = (w1 == 0 ? 1 : 2)
|
||||
storage.reserveCapacity(amount + c)
|
||||
storage.append(contentsOf: repeatElement(0, count: amount))
|
||||
storage.append(w0)
|
||||
if w1 != 0 {
|
||||
storage.append(w1)
|
||||
}
|
||||
kind = .array
|
||||
case let .slice(from: start, to: end):
|
||||
var words: [Word] = []
|
||||
words.reserveCapacity(amount + count)
|
||||
words.append(contentsOf: repeatElement(0, count: amount))
|
||||
words.append(contentsOf: storage[start ..< end])
|
||||
storage = words
|
||||
kind = .array
|
||||
case .array:
|
||||
storage.insert(contentsOf: repeatElement(0, count: amount), at: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Low and High
|
||||
|
||||
/// Split this integer into a high-order and a low-order part.
|
||||
///
|
||||
/// - Requires: count > 1
|
||||
/// - Returns: `(low, high)` such that
|
||||
/// - `self == low.add(high, shiftedBy: middleIndex)`
|
||||
/// - `high.width <= floor(width / 2)`
|
||||
/// - `low.width <= ceil(width / 2)`
|
||||
/// - Complexity: Typically O(1), but O(count) in the worst case, because high-order zero digits need to be removed after the split.
|
||||
internal var split: (high: BigUInt, low: BigUInt) {
|
||||
precondition(count > 1)
|
||||
let mid = middleIndex
|
||||
return (self.extract(mid...), self.extract(..<mid))
|
||||
}
|
||||
|
||||
/// Index of the digit at the middle of this integer.
|
||||
///
|
||||
/// - Returns: The index of the digit that is least significant in `self.high`.
|
||||
internal var middleIndex: Int {
|
||||
return (count + 1) / 2
|
||||
}
|
||||
|
||||
/// The low-order half of this BigUInt.
|
||||
///
|
||||
/// - Returns: `self[0 ..< middleIndex]`
|
||||
/// - Requires: count > 1
|
||||
internal var low: BigUInt {
|
||||
return self.extract(0 ..< middleIndex)
|
||||
}
|
||||
|
||||
/// The high-order half of this BigUInt.
|
||||
///
|
||||
/// - Returns: `self[middleIndex ..< count]`
|
||||
/// - Requires: count > 1
|
||||
internal var high: BigUInt {
|
||||
return self.extract(middleIndex ..< count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// Bitwise Ops.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
//MARK: Bitwise Operations
|
||||
|
||||
extension BigUInt {
|
||||
/// Return the ones' complement of `a`.
|
||||
///
|
||||
/// - Complexity: O(a.count)
|
||||
public static prefix func ~(a: BigUInt) -> BigUInt {
|
||||
return BigUInt(words: a.words.map { ~$0 })
|
||||
}
|
||||
|
||||
/// Calculate the bitwise OR of `a` and `b`, and store the result in `a`.
|
||||
///
|
||||
/// - Complexity: O(max(a.count, b.count))
|
||||
public static func |= (a: inout BigUInt, b: BigUInt) {
|
||||
a.reserveCapacity(b.count)
|
||||
for i in 0 ..< b.count {
|
||||
a[i] |= b[i]
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the bitwise AND of `a` and `b` and return the result.
|
||||
///
|
||||
/// - Complexity: O(max(a.count, b.count))
|
||||
public static func &= (a: inout BigUInt, b: BigUInt) {
|
||||
for i in 0 ..< Swift.max(a.count, b.count) {
|
||||
a[i] &= b[i]
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the bitwise XOR of `a` and `b` and return the result.
|
||||
///
|
||||
/// - Complexity: O(max(a.count, b.count))
|
||||
public static func ^= (a: inout BigUInt, b: BigUInt) {
|
||||
a.reserveCapacity(b.count)
|
||||
for i in 0 ..< b.count {
|
||||
a[i] ^= b[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public static prefix func ~(x: BigInt) -> BigInt {
|
||||
switch x.sign {
|
||||
case .plus:
|
||||
return BigInt(sign: .minus, magnitude: x.magnitude + 1)
|
||||
case .minus:
|
||||
return BigInt(sign: .plus, magnitude: x.magnitude - 1)
|
||||
}
|
||||
}
|
||||
|
||||
public static func &(lhs: inout BigInt, rhs: BigInt) -> BigInt {
|
||||
let left = lhs.words
|
||||
let right = rhs.words
|
||||
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
|
||||
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
|
||||
var words: [UInt] = []
|
||||
words.reserveCapacity(count)
|
||||
for i in 0 ..< count {
|
||||
words.append(left[i] & right[i])
|
||||
}
|
||||
if lhs.sign == .minus && rhs.sign == .minus {
|
||||
words.twosComplement()
|
||||
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
|
||||
public static func |(lhs: inout BigInt, rhs: BigInt) -> BigInt {
|
||||
let left = lhs.words
|
||||
let right = rhs.words
|
||||
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
|
||||
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
|
||||
var words: [UInt] = []
|
||||
words.reserveCapacity(count)
|
||||
for i in 0 ..< count {
|
||||
words.append(left[i] | right[i])
|
||||
}
|
||||
if lhs.sign == .minus || rhs.sign == .minus {
|
||||
words.twosComplement()
|
||||
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
|
||||
public static func ^(lhs: inout BigInt, rhs: BigInt) -> BigInt {
|
||||
let left = lhs.words
|
||||
let right = rhs.words
|
||||
// Note we aren't using left.count/right.count here; we account for the sign bit separately later.
|
||||
let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count)
|
||||
var words: [UInt] = []
|
||||
words.reserveCapacity(count)
|
||||
for i in 0 ..< count {
|
||||
words.append(left[i] ^ right[i])
|
||||
}
|
||||
if (lhs.sign == .minus) != (rhs.sign == .minus) {
|
||||
words.twosComplement()
|
||||
return BigInt(sign: .minus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
return BigInt(sign: .plus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
|
||||
public static func &=(lhs: inout BigInt, rhs: BigInt) {
|
||||
lhs = lhs & rhs
|
||||
}
|
||||
|
||||
public static func |=(lhs: inout BigInt, rhs: BigInt) {
|
||||
lhs = lhs | rhs
|
||||
}
|
||||
|
||||
public static func ^=(lhs: inout BigInt, rhs: BigInt) {
|
||||
lhs = lhs ^ rhs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// Codable.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2017-8-11.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
|
||||
// Little-endian to big-endian
|
||||
struct Units<Unit: FixedWidthInteger, Words: RandomAccessCollection>: RandomAccessCollection
|
||||
where Words.Element: FixedWidthInteger, Words.Index == Int {
|
||||
typealias Word = Words.Element
|
||||
let words: Words
|
||||
init(of type: Unit.Type, _ words: Words) {
|
||||
precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0)
|
||||
self.words = words
|
||||
}
|
||||
var count: Int { return (words.count * Word.bitWidth + Unit.bitWidth - 1) / Unit.bitWidth }
|
||||
var startIndex: Int { return 0 }
|
||||
var endIndex: Int { return count }
|
||||
subscript(_ index: Int) -> Unit {
|
||||
let index = count - 1 - index
|
||||
if Unit.bitWidth == Word.bitWidth {
|
||||
return Unit(words[index])
|
||||
}
|
||||
else if Unit.bitWidth > Word.bitWidth {
|
||||
let c = Unit.bitWidth / Word.bitWidth
|
||||
var unit: Unit = 0
|
||||
var j = 0
|
||||
for i in (c * index) ..< Swift.min(c * (index + 1), words.endIndex) {
|
||||
unit |= Unit(words[i]) << j
|
||||
j += Word.bitWidth
|
||||
}
|
||||
return unit
|
||||
}
|
||||
// Unit.bitWidth < Word.bitWidth
|
||||
let c = Word.bitWidth / Unit.bitWidth
|
||||
let i = index / c
|
||||
let j = index % c
|
||||
return Unit(truncatingIfNeeded: words[i] >> (j * Unit.bitWidth))
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: FixedWidthInteger {
|
||||
// Big-endian to little-endian
|
||||
init<Unit: FixedWidthInteger>(count: Int?, generator: () throws -> Unit?) rethrows {
|
||||
typealias Word = Element
|
||||
precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0)
|
||||
self = []
|
||||
if Unit.bitWidth == Word.bitWidth {
|
||||
if let count = count {
|
||||
self.reserveCapacity(count)
|
||||
}
|
||||
while let unit = try generator() {
|
||||
self.append(Word(unit))
|
||||
}
|
||||
}
|
||||
else if Unit.bitWidth > Word.bitWidth {
|
||||
let wordsPerUnit = Unit.bitWidth / Word.bitWidth
|
||||
if let count = count {
|
||||
self.reserveCapacity(count * wordsPerUnit)
|
||||
}
|
||||
while let unit = try generator() {
|
||||
var shift = Unit.bitWidth - Word.bitWidth
|
||||
while shift >= 0 {
|
||||
self.append(Word(truncatingIfNeeded: unit >> shift))
|
||||
shift -= Word.bitWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
let unitsPerWord = Word.bitWidth / Unit.bitWidth
|
||||
if let count = count {
|
||||
self.reserveCapacity((count + unitsPerWord - 1) / unitsPerWord)
|
||||
}
|
||||
var word: Word = 0
|
||||
var c = 0
|
||||
while let unit = try generator() {
|
||||
word <<= Unit.bitWidth
|
||||
word |= Word(unit)
|
||||
c += Unit.bitWidth
|
||||
if c == Word.bitWidth {
|
||||
self.append(word)
|
||||
word = 0
|
||||
c = 0
|
||||
}
|
||||
}
|
||||
if c > 0 {
|
||||
self.append(word << c)
|
||||
var shifted: Word = 0
|
||||
for i in self.indices {
|
||||
let word = self[i]
|
||||
self[i] = shifted | (word >> c)
|
||||
shifted = word << (Word.bitWidth - c)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
if let container = try? decoder.singleValueContainer(), let stringValue = try? container.decode(String.self) {
|
||||
if stringValue.hasPrefix("0x") || stringValue.hasPrefix("0X") {
|
||||
guard let bigUInt = BigUInt(stringValue.dropFirst(2), radix: 16) else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid hexadecimal BigInt string")
|
||||
}
|
||||
self.init(sign: .plus, magnitude: bigUInt)
|
||||
} else {
|
||||
guard let bigInt = BigInt(stringValue) else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid decimal BigInt string")
|
||||
}
|
||||
self = bigInt
|
||||
}
|
||||
} else {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
|
||||
// Decode sign
|
||||
let sign: BigInt.Sign
|
||||
switch try container.decode(String.self) {
|
||||
case "+":
|
||||
sign = .plus
|
||||
case "-":
|
||||
sign = .minus
|
||||
default:
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath,
|
||||
debugDescription: "Invalid big integer sign"))
|
||||
}
|
||||
|
||||
// Decode magnitude
|
||||
let words = try [UInt](count: container.count?.advanced(by: -1)) { () -> UInt64? in
|
||||
guard !container.isAtEnd else { return nil }
|
||||
return try container.decode(UInt64.self)
|
||||
}
|
||||
let magnitude = BigUInt(words: words)
|
||||
|
||||
self.init(sign: sign, magnitude: magnitude)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
try container.encode(sign == .plus ? "+" : "-")
|
||||
let units = Units(of: UInt64.self, self.magnitude.words)
|
||||
if units.isEmpty {
|
||||
try container.encode(0 as UInt64)
|
||||
}
|
||||
else {
|
||||
try container.encode(contentsOf: units)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: Codable {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try BigInt(from: decoder)
|
||||
guard value.sign == .plus else {
|
||||
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath,
|
||||
debugDescription: "BigUInt cannot hold a negative value"))
|
||||
}
|
||||
self = value.magnitude
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
try BigInt(sign: .plus, magnitude: self).encode(to: encoder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// Comparable.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
#if canImport(Foundation)
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension BigUInt: Comparable {
|
||||
#if !canImport(Foundation)
|
||||
public enum ComparisonResult: Sendable, Comparable, Hashable {
|
||||
case orderedDescending
|
||||
case orderedSame
|
||||
case orderedAscending
|
||||
}
|
||||
#endif
|
||||
|
||||
//MARK: Comparison
|
||||
|
||||
/// Compare `a` to `b` and return an `NSComparisonResult` indicating their order.
|
||||
///
|
||||
/// - Complexity: O(count)
|
||||
public static func compare(_ a: BigUInt, _ b: BigUInt) -> ComparisonResult {
|
||||
if a.count != b.count { return a.count > b.count ? .orderedDescending : .orderedAscending }
|
||||
for i in (0 ..< a.count).reversed() {
|
||||
let ad = a[i]
|
||||
let bd = b[i]
|
||||
if ad != bd { return ad > bd ? .orderedDescending : .orderedAscending }
|
||||
}
|
||||
return .orderedSame
|
||||
}
|
||||
|
||||
/// Return true iff `a` is equal to `b`.
|
||||
///
|
||||
/// - Complexity: O(count)
|
||||
public static func ==(a: BigUInt, b: BigUInt) -> Bool {
|
||||
return BigUInt.compare(a, b) == .orderedSame
|
||||
}
|
||||
|
||||
/// Return true iff `a` is less than `b`.
|
||||
///
|
||||
/// - Complexity: O(count)
|
||||
public static func <(a: BigUInt, b: BigUInt) -> Bool {
|
||||
return BigUInt.compare(a, b) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: Comparable {
|
||||
/// Return true iff `a` is equal to `b`.
|
||||
public static func ==(a: BigInt, b: BigInt) -> Bool {
|
||||
return a.sign == b.sign && a.magnitude == b.magnitude
|
||||
}
|
||||
|
||||
/// Return true iff `a` is less than `b`.
|
||||
public static func <(a: BigInt, b: BigInt) -> Bool {
|
||||
switch (a.sign, b.sign) {
|
||||
case (.plus, .plus):
|
||||
return a.magnitude < b.magnitude
|
||||
case (.plus, .minus):
|
||||
return false
|
||||
case (.minus, .plus):
|
||||
return true
|
||||
case (.minus, .minus):
|
||||
return a.magnitude > b.magnitude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// Data Conversion.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-04.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
#if canImport(Foundation)
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: NSData Conversion
|
||||
|
||||
/// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer
|
||||
public init(_ buffer: UnsafeRawBufferPointer) {
|
||||
// This assumes Word is binary.
|
||||
precondition(Word.bitWidth % 8 == 0)
|
||||
|
||||
self.init()
|
||||
|
||||
let length = buffer.count
|
||||
guard length > 0 else { return }
|
||||
let bytesPerDigit = Word.bitWidth / 8
|
||||
var index = length / bytesPerDigit
|
||||
var c = bytesPerDigit - length % bytesPerDigit
|
||||
if c == bytesPerDigit {
|
||||
c = 0
|
||||
index -= 1
|
||||
}
|
||||
|
||||
var word: Word = 0
|
||||
for byte in buffer {
|
||||
word <<= 8
|
||||
word += Word(byte)
|
||||
c += 1
|
||||
if c == bytesPerDigit {
|
||||
self[index] = word
|
||||
index -= 1
|
||||
c = 0
|
||||
word = 0
|
||||
}
|
||||
}
|
||||
assert(c == 0 && word == 0 && index == -1)
|
||||
}
|
||||
|
||||
/// Return a `UnsafeRawBufferPointer` buffer that contains the base-256 representation of this integer, in network (big-endian) byte order.
|
||||
public func serializeToBuffer() -> UnsafeRawBufferPointer {
|
||||
// This assumes Digit is binary.
|
||||
precondition(Word.bitWidth % 8 == 0)
|
||||
|
||||
let byteCount = (self.bitWidth + 7) / 8
|
||||
|
||||
let buffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: byteCount)
|
||||
|
||||
guard byteCount > 0 else { return UnsafeRawBufferPointer(start: buffer.baseAddress, count: 0) }
|
||||
|
||||
var i = byteCount - 1
|
||||
for var word in self.words {
|
||||
for _ in 0 ..< Word.bitWidth / 8 {
|
||||
buffer[i] = UInt8(word & 0xFF)
|
||||
word >>= 8
|
||||
if i == 0 {
|
||||
assert(word == 0)
|
||||
break
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
let zeroOut = UnsafeMutableBufferPointer<UInt8>(start: buffer.baseAddress, count: i)
|
||||
zeroOut.initialize(repeating: 0)
|
||||
return UnsafeRawBufferPointer(start: buffer.baseAddress, count: byteCount)
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
/// Initializes an integer from the bits stored inside a piece of `Data`.
|
||||
/// The data is assumed to be in network (big-endian) byte order.
|
||||
public init(_ data: Data) {
|
||||
self = data.withUnsafeBytes({ buffer in
|
||||
BigUInt(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order.
|
||||
public func serialize() -> Data {
|
||||
let buffer = serializeToBuffer()
|
||||
defer { buffer.deallocate() }
|
||||
guard
|
||||
let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:))
|
||||
else { return Data() }
|
||||
|
||||
return Data(bytes: pointer, count: buffer.count)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
|
||||
/// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer,
|
||||
/// where the first byte indicates sign (0 for positive, 1 for negative)
|
||||
public init(_ buffer: UnsafeRawBufferPointer) {
|
||||
// This assumes Word is binary.
|
||||
precondition(Word.bitWidth % 8 == 0)
|
||||
|
||||
self.init()
|
||||
|
||||
let length = buffer.count
|
||||
|
||||
// Serialized data for a BigInt should contain at least 2 bytes: one representing
|
||||
// the sign, and another for the non-zero magnitude. Zero is represented by an
|
||||
// empty Data struct, and negative zero is not supported.
|
||||
guard length > 1, let firstByte = buffer.first else { return }
|
||||
|
||||
// The first byte gives the sign
|
||||
// This byte is compared to a bitmask to allow additional functionality to be added
|
||||
// to this byte in the future.
|
||||
self.sign = firstByte & 0b1 == 0 ? .plus : .minus
|
||||
|
||||
self.magnitude = BigUInt(UnsafeRawBufferPointer(rebasing: buffer.dropFirst(1)))
|
||||
}
|
||||
|
||||
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative)
|
||||
public func serializeToBuffer() -> UnsafeRawBufferPointer {
|
||||
// Create a data object for the magnitude portion of the BigInt
|
||||
let magnitudeBuffer = self.magnitude.serializeToBuffer()
|
||||
|
||||
// Similar to BigUInt, a value of 0 should return an empty buffer
|
||||
guard magnitudeBuffer.count > 0 else { return magnitudeBuffer }
|
||||
|
||||
// Create a new buffer for the signed BigInt value
|
||||
let newBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: magnitudeBuffer.count + 1, alignment: 8)
|
||||
let magnitudeSection = UnsafeMutableRawBufferPointer(rebasing: newBuffer[1...])
|
||||
magnitudeSection.copyBytes(from: magnitudeBuffer)
|
||||
magnitudeBuffer.deallocate()
|
||||
|
||||
// The first byte should be 0 for a positive value, or 1 for a negative value
|
||||
// i.e., the sign bit is the LSB
|
||||
newBuffer[0] = self.sign == .plus ? 0 : 1
|
||||
|
||||
return UnsafeRawBufferPointer(start: newBuffer.baseAddress, count: newBuffer.count)
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
/// Initializes an integer from the bits stored inside a piece of `Data`.
|
||||
/// The data is assumed to be in network (big-endian) byte order with a first
|
||||
/// byte to represent the sign (0 for positive, 1 for negative)
|
||||
public init(_ data: Data) {
|
||||
self = data.withUnsafeBytes({ buffer in
|
||||
BigInt(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative)
|
||||
public func serialize() -> Data {
|
||||
let buffer = serializeToBuffer()
|
||||
defer { buffer.deallocate() }
|
||||
guard
|
||||
let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:))
|
||||
else { return Data() }
|
||||
|
||||
return Data(bytes: pointer, count: buffer.count)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//
|
||||
// Division.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
//MARK: Full-width multiplication and division
|
||||
|
||||
// TODO: Return to `where Magnitude == Self` when SR-13491 is resolved
|
||||
extension FixedWidthInteger {
|
||||
private var halfShift: Self {
|
||||
return Self(Self.bitWidth / 2)
|
||||
|
||||
}
|
||||
private var high: Self {
|
||||
return self &>> halfShift
|
||||
}
|
||||
|
||||
private var low: Self {
|
||||
let mask: Self = 1 &<< halfShift - 1
|
||||
return self & mask
|
||||
}
|
||||
|
||||
private var upshifted: Self {
|
||||
return self &<< halfShift
|
||||
}
|
||||
|
||||
private var split: (high: Self, low: Self) {
|
||||
return (self.high, self.low)
|
||||
}
|
||||
|
||||
private init(_ value: (high: Self, low: Self)) {
|
||||
self = value.high.upshifted + value.low
|
||||
}
|
||||
|
||||
/// Divide the double-width integer `dividend` by `self` and return the quotient and remainder.
|
||||
///
|
||||
/// - Requires: `dividend.high < self`, so that the result will fit in a single digit.
|
||||
/// - Complexity: O(1) with 2 divisions, 6 multiplications and ~12 or so additions/subtractions.
|
||||
internal func fastDividingFullWidth(_ dividend: (high: Self, low: Self.Magnitude)) -> (quotient: Self, remainder: Self) {
|
||||
// Division is complicated; doing it with single-digit operations is maddeningly complicated.
|
||||
// This is a Swift adaptation for "divlu2" in Hacker's Delight,
|
||||
// which is in turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1).
|
||||
precondition(dividend.high < self)
|
||||
|
||||
// This replaces the implementation in stdlib, which is much slower.
|
||||
// FIXME: Speed up stdlib. It should use full-width idiv on Intel processors, and
|
||||
// fall back to a reasonably fast algorithm elsewhere.
|
||||
|
||||
// The trick here is that we're actually implementing a 4/2 long division using half-words,
|
||||
// with the long division loop unrolled into two 3/2 half-word divisions.
|
||||
// Luckily, 3/2 half-word division can be approximated by a single full-word division operation
|
||||
// that, when the divisor is normalized, differs from the correct result by at most 2.
|
||||
|
||||
/// Find the half-word quotient in `u / vn`, which must be normalized.
|
||||
/// `u` contains three half-words in the two halves of `u.high` and the lower half of
|
||||
/// `u.low`. (The weird distribution makes for a slightly better fit with the input.)
|
||||
/// `vn` contains the normalized divisor, consisting of two half-words.
|
||||
///
|
||||
/// - Requires: u.high < vn && u.low.high == 0 && vn.leadingZeroBitCount == 0
|
||||
func quotient(dividing u: (high: Self, low: Self), by vn: Self) -> Self {
|
||||
let (vn1, vn0) = vn.split
|
||||
// Get approximate quotient.
|
||||
let (q, r) = u.high.quotientAndRemainder(dividingBy: vn1)
|
||||
let p = q * vn0
|
||||
// q is often already correct, but sometimes the approximation overshoots by at most 2.
|
||||
// The code that follows checks for this while being careful to only perform single-digit operations.
|
||||
if q.high == 0 && p <= r.upshifted + u.low { return q }
|
||||
let r2 = r + vn1
|
||||
if r2.high != 0 { return q - 1 }
|
||||
if (q - 1).high == 0 && p - vn0 <= r2.upshifted + u.low { return q - 1 }
|
||||
//assert((r + 2 * vn1).high != 0 || p - 2 * vn0 <= (r + 2 * vn1).upshifted + u.low)
|
||||
return q - 2
|
||||
}
|
||||
/// Divide 3 half-digits by 2 half-digits to get a half-digit quotient and a full-digit remainder.
|
||||
///
|
||||
/// - Requires: u.high < v && u.low.high == 0 && vn.width = width(Digit)
|
||||
func quotientAndRemainder(dividing u: (high: Self, low: Self), by v: Self) -> (quotient: Self, remainder: Self) {
|
||||
let q = quotient(dividing: u, by: v)
|
||||
// Note that `uh.low` masks off a couple of bits, and `q * v` and the
|
||||
// subtraction are likely to overflow. Despite this, the end result (remainder) will
|
||||
// still be correct and it will fit inside a single (full) Digit.
|
||||
let r = Self(u) &- q &* v
|
||||
assert(r < v)
|
||||
return (q, r)
|
||||
}
|
||||
|
||||
// Normalize the dividend and the divisor (self) such that the divisor has no leading zeroes.
|
||||
let z = Self(self.leadingZeroBitCount)
|
||||
let w = Self(Self.bitWidth) - z
|
||||
let vn = self << z
|
||||
|
||||
let un32 = (z == 0 ? dividend.high : (dividend.high &<< z) | ((dividend.low as! Self) &>> w)) // No bits are lost
|
||||
let un10 = dividend.low &<< z
|
||||
let (un1, un0) = un10.split
|
||||
|
||||
// Divide `(un32,un10)` by `vn`, splitting the full 4/2 division into two 3/2 ones.
|
||||
let (q1, un21) = quotientAndRemainder(dividing: (un32, (un1 as! Self)), by: vn)
|
||||
let (q0, rn) = quotientAndRemainder(dividing: (un21, (un0 as! Self)), by: vn)
|
||||
|
||||
// Undo normalization of the remainder and combine the two halves of the quotient.
|
||||
let mod = rn >> z
|
||||
let div = Self((q1, q0))
|
||||
return (div, mod)
|
||||
}
|
||||
|
||||
/// Return the quotient of the 3/2-word division `x/y` as a single word.
|
||||
///
|
||||
/// - Requires: (x.0, x.1) <= y && y.0.high != 0
|
||||
/// - Returns: The exact value when it fits in a single word, otherwise `Self`.
|
||||
static func approximateQuotient(dividing x: (Self, Self, Self), by y: (Self, Self)) -> Self {
|
||||
// Start with q = (x.0, x.1) / y.0, (or Word.max on overflow)
|
||||
var q: Self
|
||||
var r: Self
|
||||
if x.0 == y.0 {
|
||||
q = Self.max
|
||||
let (s, o) = x.0.addingReportingOverflow(x.1)
|
||||
if o { return q }
|
||||
r = s
|
||||
}
|
||||
else {
|
||||
(q, r) = y.0.fastDividingFullWidth((x.0, (x.1 as! Magnitude)))
|
||||
}
|
||||
// Now refine q by considering x.2 and y.1.
|
||||
// Note that since y is normalized, q * y - x is between 0 and 2.
|
||||
let (ph, pl) = q.multipliedFullWidth(by: y.1)
|
||||
if ph < r || (ph == r && pl <= x.2) { return q }
|
||||
|
||||
let (r1, ro) = r.addingReportingOverflow(y.0)
|
||||
if ro { return q - 1 }
|
||||
|
||||
let (pl1, so) = pl.subtractingReportingOverflow((y.1 as! Magnitude))
|
||||
let ph1 = (so ? ph - 1 : ph)
|
||||
|
||||
if ph1 < r1 || (ph1 == r1 && pl1 <= x.2) { return q - 1 }
|
||||
return q - 2
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Division
|
||||
|
||||
/// Divide this integer by the word `y`, leaving the quotient in its place and returning the remainder.
|
||||
///
|
||||
/// - Requires: y > 0
|
||||
/// - Complexity: O(count)
|
||||
internal mutating func divide(byWord y: Word) -> Word {
|
||||
precondition(y > 0)
|
||||
if y == 1 { return 0 }
|
||||
|
||||
var remainder: Word = 0
|
||||
for i in (0 ..< count).reversed() {
|
||||
let u = self[i]
|
||||
(self[i], remainder) = y.fastDividingFullWidth((remainder, u))
|
||||
}
|
||||
return remainder
|
||||
}
|
||||
|
||||
/// Divide this integer by the word `y` and return the resulting quotient and remainder.
|
||||
///
|
||||
/// - Requires: y > 0
|
||||
/// - Returns: (quotient, remainder) where quotient = floor(x/y), remainder = x - quotient * y
|
||||
/// - Complexity: O(x.count)
|
||||
internal func quotientAndRemainder(dividingByWord y: Word) -> (quotient: BigUInt, remainder: Word) {
|
||||
var div = self
|
||||
let mod = div.divide(byWord: y)
|
||||
return (div, mod)
|
||||
}
|
||||
|
||||
/// Divide `x` by `y`, putting the quotient in `x` and the remainder in `y`.
|
||||
/// Reusing integers like this reduces the number of allocations during the calculation.
|
||||
static func divide(_ x: inout BigUInt, by y: inout BigUInt) {
|
||||
// This is a Swift adaptation of "divmnu" from Hacker's Delight, which is in
|
||||
// turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1).
|
||||
|
||||
precondition(!y.isZero)
|
||||
|
||||
// First, let's take care of the easy cases.
|
||||
if x < y {
|
||||
(x, y) = (0, x)
|
||||
return
|
||||
}
|
||||
if y.count == 1 {
|
||||
// The single-word case reduces to a simpler loop.
|
||||
y = BigUInt(x.divide(byWord: y[0]))
|
||||
return
|
||||
}
|
||||
|
||||
// In the hard cases, we will perform the long division algorithm we learned in school.
|
||||
// It works by successively calculating the single-word quotient of the top y.count + 1
|
||||
// words of x divided by y, replacing the top of x with the remainder, and repeating
|
||||
// the process one word lower.
|
||||
//
|
||||
// The tricky part is that the algorithm needs to be able to do n+1/n word divisions,
|
||||
// but we only have a primitive for dividing two words by a single
|
||||
// word. (Remember that this step is also tricky when we do it on paper!)
|
||||
//
|
||||
// The solution is that the long division can be approximated by a single full division
|
||||
// using just the most significant words. We can then use multiplications and
|
||||
// subtractions to refine the approximation until we get the correct quotient word.
|
||||
//
|
||||
// We could do this by doing a simple 2/1 full division, but Knuth goes one step further,
|
||||
// and implements a 3/2 division. This results in an exact approximation in the
|
||||
// vast majority of cases, eliminating an extra subtraction over big integers.
|
||||
//
|
||||
// The function `approximateQuotient` above implements Knuth's 3/2 division algorithm.
|
||||
// It requires that the divisor's most significant word is larger than
|
||||
// Word.max / 2. This ensures that the approximation has tiny error bounds,
|
||||
// which is what makes this entire approach viable.
|
||||
// To satisfy this requirement, we will normalize the division by multiplying
|
||||
// both the divisor and the dividend by the same (small) factor.
|
||||
let z = y.leadingZeroBitCount
|
||||
y <<= z
|
||||
x <<= z // We'll calculate the remainder in the normalized dividend.
|
||||
var quotient = BigUInt()
|
||||
assert(y.leadingZeroBitCount == 0)
|
||||
|
||||
// We're ready to start the long division!
|
||||
let dc = y.count
|
||||
let d1 = y[dc - 1]
|
||||
let d0 = y[dc - 2]
|
||||
var product: BigUInt = 0
|
||||
for j in (dc ... x.count).reversed() {
|
||||
// Approximate dividing the top dc+1 words of `remainder` using the topmost 3/2 words.
|
||||
let r2 = x[j]
|
||||
let r1 = x[j - 1]
|
||||
let r0 = x[j - 2]
|
||||
let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0))
|
||||
|
||||
// Multiply the entire divisor with `q` and subtract the result from remainder.
|
||||
// Normalization ensures the 3/2 quotient will either be exact for the full division, or
|
||||
// it may overshoot by at most 1, in which case the product will be greater
|
||||
// than the remainder.
|
||||
product.load(y)
|
||||
product.multiply(byWord: q)
|
||||
if product <= x.extract(j - dc ..< j + 1) {
|
||||
x.subtract(product, shiftedBy: j - dc)
|
||||
quotient[j - dc] = q
|
||||
}
|
||||
else {
|
||||
// This case is extremely rare -- it has a probability of 1/2^(Word.bitWidth - 1).
|
||||
x.add(y, shiftedBy: j - dc)
|
||||
x.subtract(product, shiftedBy: j - dc)
|
||||
quotient[j - dc] = q - 1
|
||||
}
|
||||
}
|
||||
// The remainder's normalization needs to be undone, but otherwise we're done.
|
||||
x >>= z
|
||||
y = x
|
||||
x = quotient
|
||||
}
|
||||
|
||||
/// Divide `x` by `y`, putting the remainder in `x`.
|
||||
mutating func formRemainder(dividingBy y: BigUInt, normalizedBy shift: Int) {
|
||||
precondition(!y.isZero)
|
||||
assert(y.leadingZeroBitCount == 0)
|
||||
if y.count == 1 {
|
||||
let remainder = self.divide(byWord: y[0] >> shift)
|
||||
self.load(BigUInt(remainder))
|
||||
return
|
||||
}
|
||||
self <<= shift
|
||||
if self >= y {
|
||||
let dc = y.count
|
||||
let d1 = y[dc - 1]
|
||||
let d0 = y[dc - 2]
|
||||
var product: BigUInt = 0
|
||||
for j in (dc ... self.count).reversed() {
|
||||
let r2 = self[j]
|
||||
let r1 = self[j - 1]
|
||||
let r0 = self[j - 2]
|
||||
let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0))
|
||||
product.load(y)
|
||||
product.multiply(byWord: q)
|
||||
if product <= self.extract(j - dc ..< j + 1) {
|
||||
self.subtract(product, shiftedBy: j - dc)
|
||||
}
|
||||
else {
|
||||
self.add(y, shiftedBy: j - dc)
|
||||
self.subtract(product, shiftedBy: j - dc)
|
||||
}
|
||||
}
|
||||
}
|
||||
self >>= shift
|
||||
}
|
||||
|
||||
|
||||
/// Divide this integer by `y` and return the resulting quotient and remainder.
|
||||
///
|
||||
/// - Requires: `y > 0`
|
||||
/// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y`
|
||||
/// - Complexity: O(count^2)
|
||||
public func quotientAndRemainder(dividingBy y: BigUInt) -> (quotient: BigUInt, remainder: BigUInt) {
|
||||
var x = self
|
||||
var y = y
|
||||
BigUInt.divide(&x, by: &y)
|
||||
return (x, y)
|
||||
}
|
||||
|
||||
/// Divide `x` by `y` and return the quotient.
|
||||
///
|
||||
/// - Note: Use `divided(by:)` if you also need the remainder.
|
||||
public static func /(x: BigUInt, y: BigUInt) -> BigUInt {
|
||||
return x.quotientAndRemainder(dividingBy: y).quotient
|
||||
}
|
||||
|
||||
/// Divide `x` by `y` and return the remainder.
|
||||
///
|
||||
/// - Note: Use `divided(by:)` if you also need the remainder.
|
||||
public static func %(x: BigUInt, y: BigUInt) -> BigUInt {
|
||||
var x = x
|
||||
let shift = y.leadingZeroBitCount
|
||||
x.formRemainder(dividingBy: y << shift, normalizedBy: shift)
|
||||
return x
|
||||
}
|
||||
|
||||
/// Divide `x` by `y` and store the quotient in `x`.
|
||||
///
|
||||
/// - Note: Use `divided(by:)` if you also need the remainder.
|
||||
public static func /=(x: inout BigUInt, y: BigUInt) {
|
||||
var y = y
|
||||
BigUInt.divide(&x, by: &y)
|
||||
}
|
||||
|
||||
/// Divide `x` by `y` and store the remainder in `x`.
|
||||
///
|
||||
/// - Note: Use `divided(by:)` if you also need the remainder.
|
||||
public static func %=(x: inout BigUInt, y: BigUInt) {
|
||||
let shift = y.leadingZeroBitCount
|
||||
x.formRemainder(dividingBy: y << shift, normalizedBy: shift)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Divide this integer by `y` and return the resulting quotient and remainder.
|
||||
///
|
||||
/// - Requires: `y > 0`
|
||||
/// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y`
|
||||
/// - Complexity: O(count^2)
|
||||
public func quotientAndRemainder(dividingBy y: BigInt) -> (quotient: BigInt, remainder: BigInt) {
|
||||
var a = self.magnitude
|
||||
var b = y.magnitude
|
||||
BigUInt.divide(&a, by: &b)
|
||||
return (BigInt(sign: self.sign == y.sign ? .plus : .minus, magnitude: a),
|
||||
BigInt(sign: self.sign, magnitude: b))
|
||||
}
|
||||
|
||||
/// Divide `a` by `b` and return the quotient. Traps if `b` is zero.
|
||||
public static func /(a: BigInt, b: BigInt) -> BigInt {
|
||||
return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude / b.magnitude)
|
||||
}
|
||||
|
||||
/// Divide `a` by `b` and return the remainder. The result has the same sign as `a`.
|
||||
public static func %(a: BigInt, b: BigInt) -> BigInt {
|
||||
return BigInt(sign: a.sign, magnitude: a.magnitude % b.magnitude)
|
||||
}
|
||||
|
||||
/// Return the result of `a` mod `b`. The result is always a nonnegative integer that is less than the absolute value of `b`.
|
||||
public func modulus(_ mod: BigInt) -> BigInt {
|
||||
let remainder = self.magnitude % mod.magnitude
|
||||
return BigInt(
|
||||
self.sign == .minus && !remainder.isZero
|
||||
? mod.magnitude - remainder
|
||||
: remainder)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Divide `a` by `b` storing the quotient in `a`.
|
||||
public static func /=(a: inout BigInt, b: BigInt) { a = a / b }
|
||||
/// Divide `a` by `b` storing the remainder in `a`.
|
||||
public static func %=(a: inout BigInt, b: BigInt) { a = a % b }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// Exponentiation.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Exponentiation
|
||||
|
||||
/// Returns this integer raised to the power `exponent`.
|
||||
///
|
||||
/// This function calculates the result by [successively squaring the base while halving the exponent][expsqr].
|
||||
///
|
||||
/// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring
|
||||
///
|
||||
/// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is
|
||||
/// a simple integer value. If you want to calculate big exponents, you'll probably need to use
|
||||
/// the modulo arithmetic variant.
|
||||
/// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.)
|
||||
/// - SeeAlso: `BigUInt.power(_:, modulus:)`
|
||||
/// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too.
|
||||
public func power(_ exponent: Int) -> BigUInt {
|
||||
if exponent == 0 { return 1 }
|
||||
if exponent == 1 { return self }
|
||||
if exponent < 0 {
|
||||
precondition(!self.isZero)
|
||||
return self == 1 ? 1 : 0
|
||||
}
|
||||
if self <= 1 { return self }
|
||||
var result = BigUInt(1)
|
||||
var b = self
|
||||
var e = exponent
|
||||
while e > 0 {
|
||||
if e & 1 == 1 {
|
||||
result *= b
|
||||
}
|
||||
e >>= 1
|
||||
b *= b
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`.
|
||||
///
|
||||
/// Uses the [right-to-left binary method][rtlb].
|
||||
///
|
||||
/// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method
|
||||
///
|
||||
/// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch
|
||||
public func power(_ exponent: BigUInt, modulus: BigUInt) -> BigUInt {
|
||||
precondition(!modulus.isZero)
|
||||
if modulus == (1 as BigUInt) { return 0 }
|
||||
let shift = modulus.leadingZeroBitCount
|
||||
let normalizedModulus = modulus << shift
|
||||
var result = BigUInt(1)
|
||||
var b = self
|
||||
b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
|
||||
for var e in exponent.words {
|
||||
for _ in 0 ..< Word.bitWidth {
|
||||
if e & 1 == 1 {
|
||||
result *= b
|
||||
result.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
|
||||
}
|
||||
e >>= 1
|
||||
b *= b
|
||||
b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Returns this integer raised to the power `exponent`.
|
||||
///
|
||||
/// This function calculates the result by [successively squaring the base while halving the exponent][expsqr].
|
||||
///
|
||||
/// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring
|
||||
///
|
||||
/// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is
|
||||
/// a simple integer value. If you want to calculate big exponents, you'll probably need to use
|
||||
/// the modulo arithmetic variant.
|
||||
/// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.)
|
||||
/// - SeeAlso: `BigUInt.power(_:, modulus:)`
|
||||
/// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too.
|
||||
public func power(_ exponent: Int) -> BigInt {
|
||||
return BigInt(sign: self.sign == .minus && exponent & 1 != 0 ? .minus : .plus,
|
||||
magnitude: self.magnitude.power(exponent))
|
||||
}
|
||||
|
||||
/// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`.
|
||||
///
|
||||
/// Uses the [right-to-left binary method][rtlb].
|
||||
///
|
||||
/// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method
|
||||
///
|
||||
/// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch
|
||||
public func power(_ exponent: BigInt, modulus: BigInt) -> BigInt {
|
||||
precondition(!modulus.isZero)
|
||||
if modulus.magnitude == 1 { return 0 }
|
||||
if exponent.isZero { return 1 }
|
||||
if exponent == 1 { return self.modulus(modulus) }
|
||||
if exponent < 0 {
|
||||
precondition(!self.isZero)
|
||||
guard magnitude == 1 else { return 0 }
|
||||
guard sign == .minus else { return 1 }
|
||||
guard exponent.magnitude[0] & 1 != 0 else { return 1 }
|
||||
return BigInt(modulus.magnitude - 1)
|
||||
}
|
||||
let power = self.magnitude.power(exponent.magnitude,
|
||||
modulus: modulus.magnitude)
|
||||
if self.sign == .plus || exponent.magnitude[0] & 1 == 0 || power.isZero {
|
||||
return BigInt(power)
|
||||
}
|
||||
return BigInt(modulus.magnitude - power)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Floating Point Conversion.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2017-08-11.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
#if canImport(Foundation)
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
extension BigUInt {
|
||||
public init?<T: BinaryFloatingPoint>(exactly source: T) {
|
||||
guard source.isFinite else { return nil }
|
||||
guard !source.isZero else { self = 0; return }
|
||||
guard source.sign == .plus else { return nil }
|
||||
let value = source.rounded(.towardZero)
|
||||
guard value == source else { return nil }
|
||||
assert(value.floatingPointClass == .positiveNormal)
|
||||
assert(value.exponent >= 0)
|
||||
let significand = value.significandBitPattern
|
||||
self = (BigUInt(1) << value.exponent) + BigUInt(significand) >> (T.significandBitCount - Int(value.exponent))
|
||||
}
|
||||
|
||||
public init<T: BinaryFloatingPoint>(_ source: T) {
|
||||
self.init(exactly: source.rounded(.towardZero))!
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
public init?(exactly source: Decimal) {
|
||||
guard source.exponent >= 0 else { return nil }
|
||||
self.init(commonDecimal: source)
|
||||
}
|
||||
|
||||
public init?(truncating source: Decimal) {
|
||||
self.init(commonDecimal: source)
|
||||
}
|
||||
|
||||
private init?(commonDecimal source: Decimal) {
|
||||
var integer = source
|
||||
if source.exponent < 0 {
|
||||
var source = source
|
||||
NSDecimalRound(&integer, &source, 0, .down)
|
||||
}
|
||||
|
||||
guard !integer.isZero else { self = 0; return }
|
||||
guard integer.isFinite else { return nil }
|
||||
guard integer.sign == .plus else { return nil }
|
||||
assert(integer.floatingPointClass == .positiveNormal)
|
||||
|
||||
#if os(Linux) || os(Android) || os(Windows) || os(WASI)
|
||||
// `Decimal._mantissa` has an internal access level on linux, and it might get
|
||||
// deprecated in the future, so keeping the string implementation around for now.
|
||||
let significand = BigUInt("\(integer.significand)")!
|
||||
#else
|
||||
let significand = {
|
||||
var start = BigUInt(0)
|
||||
for (place, value) in integer.significand.mantissaParts.enumerated() {
|
||||
guard value > 0 else { continue }
|
||||
start += (1 << (place * 16)) * BigUInt(value)
|
||||
}
|
||||
return start
|
||||
}()
|
||||
#endif
|
||||
let exponent = BigUInt(10).power(integer.exponent)
|
||||
|
||||
self = significand * exponent
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public init?<T: BinaryFloatingPoint>(exactly source: T) {
|
||||
guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil }
|
||||
let sign = BigInt.Sign(source.sign)
|
||||
self.init(sign: sign, magnitude: magnitude)
|
||||
}
|
||||
|
||||
public init<T: BinaryFloatingPoint>(_ source: T) {
|
||||
self.init(exactly: source.rounded(.towardZero))!
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
public init?(exactly source: Decimal) {
|
||||
guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil }
|
||||
let sign = BigInt.Sign(source.sign)
|
||||
self.init(sign: sign, magnitude: magnitude)
|
||||
}
|
||||
|
||||
public init?(truncating source: Decimal) {
|
||||
guard let magnitude = BigUInt(truncating: source.magnitude) else { return nil }
|
||||
let sign = BigInt.Sign(source.sign)
|
||||
self.init(sign: sign, magnitude: magnitude)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension BinaryFloatingPoint where RawExponent: FixedWidthInteger, RawSignificand: FixedWidthInteger {
|
||||
public init(_ value: BigInt) {
|
||||
guard !value.isZero else { self = 0; return }
|
||||
let v = value.magnitude
|
||||
let bitWidth = v.bitWidth
|
||||
var exponent = bitWidth - 1
|
||||
let shift = bitWidth - Self.significandBitCount - 1
|
||||
var significand = value.magnitude >> (shift - 1)
|
||||
if significand[0] & 3 == 3 { // Handle rounding
|
||||
significand >>= 1
|
||||
significand += 1
|
||||
if significand.trailingZeroBitCount >= Self.significandBitCount {
|
||||
exponent += 1
|
||||
}
|
||||
}
|
||||
else {
|
||||
significand >>= 1
|
||||
}
|
||||
let bias = 1 << (Self.exponentBitCount - 1) - 1
|
||||
guard exponent <= bias else { self = Self.infinity; return }
|
||||
significand &= 1 << Self.significandBitCount - 1
|
||||
self = Self.init(sign: value.sign == .plus ? .plus : .minus,
|
||||
exponentBitPattern: RawExponent(bias + exponent),
|
||||
significandBitPattern: RawSignificand(significand))
|
||||
}
|
||||
|
||||
public init(_ value: BigUInt) {
|
||||
self.init(BigInt(sign: .plus, magnitude: value))
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt.Sign {
|
||||
public init(_ sign: FloatingPointSign) {
|
||||
switch sign {
|
||||
case .plus:
|
||||
self = .plus
|
||||
case .minus:
|
||||
self = .minus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
public extension Decimal {
|
||||
init(_ value: BigUInt) {
|
||||
guard
|
||||
value < BigUInt(exactly: Decimal.greatestFiniteMagnitude)!
|
||||
else {
|
||||
self = .greatestFiniteMagnitude
|
||||
return
|
||||
}
|
||||
guard !value.isZero else { self = 0; return }
|
||||
|
||||
self.init(string: "\(value)")!
|
||||
}
|
||||
|
||||
init(_ value: BigInt) {
|
||||
if value >= 0 {
|
||||
self.init(BigUInt(value))
|
||||
} else {
|
||||
self.init(value.magnitude)
|
||||
self *= -1
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if canImport(Foundation) && !(os(Linux) || os(Android) || os(Windows) || os(WASI))
|
||||
private extension Decimal {
|
||||
var mantissaParts: [UInt16] {
|
||||
[
|
||||
_mantissa.0,
|
||||
_mantissa.1,
|
||||
_mantissa.2,
|
||||
_mantissa.3,
|
||||
_mantissa.4,
|
||||
_mantissa.5,
|
||||
_mantissa.6,
|
||||
_mantissa.7,
|
||||
]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// GCD.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Greatest Common Divisor
|
||||
|
||||
/// Returns the greatest common divisor of `self` and `b`.
|
||||
///
|
||||
/// - Complexity: O(count^2) where count = max(self.count, b.count)
|
||||
public func greatestCommonDivisor(with b: BigUInt) -> BigUInt {
|
||||
// This is Stein's algorithm: https://en.wikipedia.org/wiki/Binary_GCD_algorithm
|
||||
if self.isZero { return b }
|
||||
if b.isZero { return self }
|
||||
|
||||
let az = self.trailingZeroBitCount
|
||||
let bz = b.trailingZeroBitCount
|
||||
let twos = Swift.min(az, bz)
|
||||
|
||||
var (x, y) = (self >> az, b >> bz)
|
||||
if x < y { swap(&x, &y) }
|
||||
|
||||
while !x.isZero {
|
||||
x >>= x.trailingZeroBitCount
|
||||
if x < y { swap(&x, &y) }
|
||||
x -= y
|
||||
}
|
||||
return y << twos
|
||||
}
|
||||
|
||||
/// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse],
|
||||
/// or `nil` if there is no such number.
|
||||
///
|
||||
/// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers
|
||||
///
|
||||
/// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`.
|
||||
/// - Requires: modulus > 1
|
||||
/// - Complexity: O(count^3)
|
||||
public func inverse(_ modulus: BigUInt) -> BigUInt? {
|
||||
precondition(modulus > 1)
|
||||
var t1 = BigInt(0)
|
||||
var t2 = BigInt(1)
|
||||
var r1 = modulus
|
||||
var r2 = self
|
||||
while !r2.isZero {
|
||||
let quotient = r1 / r2
|
||||
(t1, t2) = (t2, t1 - BigInt(quotient) * t2)
|
||||
(r1, r2) = (r2, r1 - quotient * r2)
|
||||
}
|
||||
if r1 > 1 { return nil }
|
||||
if t1.sign == .minus { return modulus - t1.magnitude }
|
||||
return t1.magnitude
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Returns the greatest common divisor of `a` and `b`.
|
||||
///
|
||||
/// - Complexity: O(count^2) where count = max(a.count, b.count)
|
||||
public func greatestCommonDivisor(with b: BigInt) -> BigInt {
|
||||
return BigInt(self.magnitude.greatestCommonDivisor(with: b.magnitude))
|
||||
}
|
||||
|
||||
/// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse],
|
||||
/// or `nil` if there is no such number.
|
||||
///
|
||||
/// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers
|
||||
///
|
||||
/// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`.
|
||||
/// - Requires: modulus.magnitude > 1
|
||||
/// - Complexity: O(count^3)
|
||||
public func inverse(_ modulus: BigInt) -> BigInt? {
|
||||
guard let inv = self.magnitude.inverse(modulus.magnitude) else { return nil }
|
||||
return BigInt(self.sign == .plus || inv.isZero ? inv : modulus.magnitude - inv)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Hashable.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt: Hashable {
|
||||
//MARK: Hashing
|
||||
|
||||
/// Append this `BigUInt` to the specified hasher.
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
for word in self.words {
|
||||
hasher.combine(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: Hashable {
|
||||
/// Append this `BigInt` to the specified hasher.
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(sign)
|
||||
hasher.combine(magnitude)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Integer Conversion.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2017-08-11.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
public init?<T: BinaryInteger>(exactly source: T) {
|
||||
guard source >= (0 as T) else { return nil }
|
||||
if source.bitWidth <= 2 * Word.bitWidth {
|
||||
var it = source.words.makeIterator()
|
||||
self.init(low: it.next() ?? 0, high: it.next() ?? 0)
|
||||
precondition(it.next() == nil, "Length of BinaryInteger.words is greater than its bitWidth")
|
||||
}
|
||||
else {
|
||||
self.init(words: source.words)
|
||||
}
|
||||
}
|
||||
|
||||
public init<T: BinaryInteger>(_ source: T) {
|
||||
precondition(source >= (0 as T), "BigUInt cannot represent negative values")
|
||||
self.init(exactly: source)!
|
||||
}
|
||||
|
||||
public init<T: BinaryInteger>(truncatingIfNeeded source: T) {
|
||||
self.init(words: source.words)
|
||||
}
|
||||
|
||||
public init<T: BinaryInteger>(clamping source: T) {
|
||||
if source <= (0 as T) {
|
||||
self.init()
|
||||
}
|
||||
else {
|
||||
self.init(words: source.words)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public init() {
|
||||
self.init(sign: .plus, magnitude: 0)
|
||||
}
|
||||
|
||||
/// Initializes a new signed big integer with the same value as the specified unsigned big integer.
|
||||
public init(_ integer: BigUInt) {
|
||||
self.magnitude = integer
|
||||
self.sign = .plus
|
||||
}
|
||||
|
||||
public init<T>(_ source: T) where T : BinaryInteger {
|
||||
if source >= (0 as T) {
|
||||
self.init(sign: .plus, magnitude: BigUInt(source))
|
||||
}
|
||||
else {
|
||||
var words = Array(source.words)
|
||||
words.twosComplement()
|
||||
self.init(sign: .minus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
}
|
||||
|
||||
public init?<T>(exactly source: T) where T : BinaryInteger {
|
||||
self.init(source)
|
||||
}
|
||||
|
||||
public init<T>(clamping source: T) where T : BinaryInteger {
|
||||
self.init(source)
|
||||
}
|
||||
|
||||
public init<T>(truncatingIfNeeded source: T) where T : BinaryInteger {
|
||||
self.init(source)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: ExpressibleByIntegerLiteral {
|
||||
/// Initialize a new big integer from an integer literal.
|
||||
public init(integerLiteral value: UInt64) {
|
||||
self.init(value)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: ExpressibleByIntegerLiteral {
|
||||
/// Initialize a new big integer from an integer literal.
|
||||
public init(integerLiteral value: Int64) {
|
||||
self.init(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// Multiplication.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
|
||||
//MARK: Multiplication
|
||||
|
||||
/// Multiply this big integer by a single word, and store the result in place of the original big integer.
|
||||
///
|
||||
/// - Complexity: O(count)
|
||||
public mutating func multiply(byWord y: Word) {
|
||||
guard y != 0 else { self = 0; return }
|
||||
guard y != 1 else { return }
|
||||
var carry: Word = 0
|
||||
let c = self.count
|
||||
for i in 0 ..< c {
|
||||
let (h, l) = self[i].multipliedFullWidth(by: y)
|
||||
let (low, o) = l.addingReportingOverflow(carry)
|
||||
self[i] = low
|
||||
carry = (o ? h + 1 : h)
|
||||
}
|
||||
self[c] = carry
|
||||
}
|
||||
|
||||
/// Multiply this big integer by a single Word, and return the result.
|
||||
///
|
||||
/// - Complexity: O(count)
|
||||
public func multiplied(byWord y: Word) -> BigUInt {
|
||||
var r = self
|
||||
r.multiply(byWord: y)
|
||||
return r
|
||||
}
|
||||
|
||||
/// Multiply `x` by `y`, and add the result to this integer, optionally shifted `shift` words to the left.
|
||||
///
|
||||
/// - Note: This is the fused multiply/shift/add operation; it is more efficient than doing the components
|
||||
/// individually. (The fused operation doesn't need to allocate space for temporary big integers.)
|
||||
/// `self` is set to `self + (x * y) << (shift * 2^Word.bitWidth)`
|
||||
/// - Complexity: O(count)
|
||||
public mutating func multiplyAndAdd(_ x: BigUInt, _ y: Word, shiftedBy shift: Int = 0) {
|
||||
precondition(shift >= 0)
|
||||
guard y != 0 && x.count > 0 else { return }
|
||||
guard y != 1 else { self.add(x, shiftedBy: shift); return }
|
||||
var mulCarry: Word = 0
|
||||
var addCarry = false
|
||||
let xc = x.count
|
||||
var xi = 0
|
||||
while xi < xc || addCarry || mulCarry > 0 {
|
||||
let (h, l) = x[xi].multipliedFullWidth(by: y)
|
||||
let (low, o) = l.addingReportingOverflow(mulCarry)
|
||||
mulCarry = (o ? h + 1 : h)
|
||||
|
||||
let ai = shift + xi
|
||||
let (sum1, so1) = self[ai].addingReportingOverflow(low)
|
||||
if addCarry {
|
||||
let (sum2, so2) = sum1.addingReportingOverflow(1)
|
||||
self[ai] = sum2
|
||||
addCarry = so1 || so2
|
||||
}
|
||||
else {
|
||||
self[ai] = sum1
|
||||
addCarry = so1
|
||||
}
|
||||
xi += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiply this integer by `y` and return the result.
|
||||
///
|
||||
/// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than
|
||||
/// `BigUInt.directMultiplicationLimit` words.
|
||||
/// - Complexity: O(n^log2(3))
|
||||
public func multiplied(by y: BigUInt) -> BigUInt {
|
||||
// This method is mostly defined for symmetry with the rest of the arithmetic operations.
|
||||
return self * y
|
||||
}
|
||||
|
||||
/// Multiplication switches to an asymptotically better recursive algorithm when arguments have more words than this limit.
|
||||
public static let directMultiplicationLimit: Int = 1024
|
||||
|
||||
/// Multiply `a` by `b` and return the result.
|
||||
///
|
||||
/// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than
|
||||
/// `BigUInt.directMultiplicationLimit` words.
|
||||
/// - Complexity: O(n^log2(3))
|
||||
public static func *(x: BigUInt, y: BigUInt) -> BigUInt {
|
||||
let xc = x.count
|
||||
let yc = y.count
|
||||
if xc == 0 { return BigUInt() }
|
||||
if yc == 0 { return BigUInt() }
|
||||
if yc == 1 { return x.multiplied(byWord: y[0]) }
|
||||
if xc == 1 { return y.multiplied(byWord: x[0]) }
|
||||
|
||||
if Swift.min(xc, yc) <= BigUInt.directMultiplicationLimit {
|
||||
// Long multiplication.
|
||||
let left = (xc < yc ? y : x)
|
||||
let right = (xc < yc ? x : y)
|
||||
var result = BigUInt()
|
||||
for i in (0 ..< right.count).reversed() {
|
||||
result.multiplyAndAdd(left, right[i], shiftedBy: i)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if yc < xc {
|
||||
let (xh, xl) = x.split
|
||||
var r = xl * y
|
||||
r.add(xh * y, shiftedBy: x.middleIndex)
|
||||
return r
|
||||
}
|
||||
else if xc < yc {
|
||||
let (yh, yl) = y.split
|
||||
var r = yl * x
|
||||
r.add(yh * x, shiftedBy: y.middleIndex)
|
||||
return r
|
||||
}
|
||||
|
||||
let shift = x.middleIndex
|
||||
|
||||
// Karatsuba multiplication:
|
||||
// x * y = <a,b> * <c,d> = <ac, ac + bd - (a-b)(c-d), bd> (ignoring carry)
|
||||
let (a, b) = x.split
|
||||
let (c, d) = y.split
|
||||
|
||||
let high = a * c
|
||||
let low = b * d
|
||||
let xp = a >= b
|
||||
let yp = c >= d
|
||||
let xm = (xp ? a - b : b - a)
|
||||
let ym = (yp ? c - d : d - c)
|
||||
let m = xm * ym
|
||||
|
||||
var r = low
|
||||
r.add(high, shiftedBy: 2 * shift)
|
||||
r.add(low, shiftedBy: shift)
|
||||
r.add(high, shiftedBy: shift)
|
||||
if xp == yp {
|
||||
r.subtract(m, shiftedBy: shift)
|
||||
}
|
||||
else {
|
||||
r.add(m, shiftedBy: shift)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/// Multiply `a` by `b` and store the result in `a`.
|
||||
public static func *=(a: inout BigUInt, b: BigUInt) {
|
||||
a = a * b
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Multiply `a` with `b` and return the result.
|
||||
public static func *(a: BigInt, b: BigInt) -> BigInt {
|
||||
return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude * b.magnitude)
|
||||
}
|
||||
|
||||
/// Multiply `a` with `b` in place.
|
||||
public static func *=(a: inout BigInt, b: BigInt) { a = a * b }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//
|
||||
// Prime Test.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-04.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
/// The first several [prime numbers][primes].
|
||||
///
|
||||
/// [primes]: https://oeis.org/A000040
|
||||
let primes: [BigUInt.Word] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41]
|
||||
|
||||
/// The ith element in this sequence is the smallest composite number that passes the strong probable prime test
|
||||
/// for all of the first (i+1) primes.
|
||||
///
|
||||
/// This is sequence [A014233](http://oeis.org/A014233) on the [Online Encyclopaedia of Integer Sequences](http://oeis.org).
|
||||
let pseudoPrimes: [BigUInt] = [
|
||||
/* 2 */ 2_047,
|
||||
/* 3 */ 1_373_653,
|
||||
/* 5 */ 25_326_001,
|
||||
/* 7 */ 3_215_031_751,
|
||||
/* 11 */ 2_152_302_898_747,
|
||||
/* 13 */ 3_474_749_660_383,
|
||||
/* 17 */ 341_550_071_728_321,
|
||||
/* 19 */ 341_550_071_728_321,
|
||||
/* 23 */ 3_825_123_056_546_413_051,
|
||||
/* 29 */ 3_825_123_056_546_413_051,
|
||||
/* 31 */ 3_825_123_056_546_413_051,
|
||||
/* 37 */ "318665857834031151167461",
|
||||
/* 41 */ "3317044064679887385961981",
|
||||
]
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Primality Testing
|
||||
|
||||
/// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base.
|
||||
///
|
||||
/// [sppt]: https://en.wikipedia.org/wiki/Probable_prime
|
||||
public func isStrongProbablePrime(_ base: BigUInt) -> Bool {
|
||||
precondition(base > (1 as BigUInt))
|
||||
precondition(self > (0 as BigUInt))
|
||||
let dec = self - 1
|
||||
|
||||
let r = dec.trailingZeroBitCount
|
||||
let d = dec >> r
|
||||
|
||||
var test = base.power(d, modulus: self)
|
||||
if test == 1 || test == dec { return true }
|
||||
|
||||
if r > 0 {
|
||||
let shift = self.leadingZeroBitCount
|
||||
let normalized = self << shift
|
||||
for _ in 1 ..< r {
|
||||
test *= test
|
||||
test.formRemainder(dividingBy: normalized, normalizedBy: shift)
|
||||
if test == 1 {
|
||||
return false
|
||||
}
|
||||
if test == dec { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime.
|
||||
///
|
||||
/// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations,
|
||||
/// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default,
|
||||
/// but you may specify your own choice.
|
||||
///
|
||||
/// To speed things up, the function checks if `self` is divisible by the first few prime numbers before
|
||||
/// diving into (slower) Miller-Rabin testing.
|
||||
///
|
||||
/// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to
|
||||
/// return a correct result.
|
||||
///
|
||||
/// [mrpt]: https://en.wikipedia.org/wiki/Miller–Rabin_primality_test
|
||||
public func isPrime(rounds: Int = 10) -> Bool {
|
||||
if count <= 1 && self[0] < 2 { return false }
|
||||
if count == 1 && self[0] < 4 { return true }
|
||||
|
||||
// Even numbers above 2 aren't prime.
|
||||
if self[0] & 1 == 0 { return false }
|
||||
|
||||
// Quickly check for small primes.
|
||||
for i in 1 ..< primes.count {
|
||||
let p = primes[i]
|
||||
if self.count == 1 && self[0] == p {
|
||||
return true
|
||||
}
|
||||
if self.quotientAndRemainder(dividingByWord: p).remainder == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Give an exact answer when we can.
|
||||
if self < pseudoPrimes.last! {
|
||||
for i in 0 ..< pseudoPrimes.count {
|
||||
guard isStrongProbablePrime(BigUInt(primes[i])) else {
|
||||
break
|
||||
}
|
||||
if self < pseudoPrimes[i] {
|
||||
// `self` is below the lowest pseudoprime corresponding to the prime bases we tested. It's a prime!
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Otherwise do as many rounds of random SPPT as required.
|
||||
for _ in 0 ..< rounds {
|
||||
let random = BigUInt.randomInteger(lessThan: self - 2) + 2
|
||||
guard isStrongProbablePrime(random) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Well, it smells primey to me.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
//MARK: Primality Testing
|
||||
|
||||
/// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base.
|
||||
///
|
||||
/// [sppt]: https://en.wikipedia.org/wiki/Probable_prime
|
||||
public func isStrongProbablePrime(_ base: BigInt) -> Bool {
|
||||
precondition(base.sign == .plus)
|
||||
if self.sign == .minus { return false }
|
||||
return self.magnitude.isStrongProbablePrime(base.magnitude)
|
||||
}
|
||||
|
||||
/// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime.
|
||||
///
|
||||
/// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations,
|
||||
/// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default,
|
||||
/// but you may specify your own choice.
|
||||
///
|
||||
/// To speed things up, the function checks if `self` is divisible by the first few prime numbers before
|
||||
/// diving into (slower) Miller-Rabin testing.
|
||||
///
|
||||
/// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to
|
||||
/// return a correct result.
|
||||
///
|
||||
/// [mrpt]: https://en.wikipedia.org/wiki/Miller–Rabin_primality_test
|
||||
public func isPrime(rounds: Int = 10) -> Bool {
|
||||
if self.sign == .minus { return false }
|
||||
return self.magnitude.isPrime(rounds: rounds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// Random.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-04.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
/// Create a big unsigned integer consisting of `width` uniformly distributed random bits.
|
||||
///
|
||||
/// - Parameter width: The maximum number of one bits in the result.
|
||||
/// - Parameter generator: The source of randomness.
|
||||
/// - Returns: A big unsigned integer less than `1 << width`.
|
||||
public static func randomInteger<RNG: RandomNumberGenerator>(withMaximumWidth width: Int, using generator: inout RNG) -> BigUInt {
|
||||
var result = BigUInt.zero
|
||||
var bitsLeft = width
|
||||
var i = 0
|
||||
let wordsNeeded = (width + Word.bitWidth - 1) / Word.bitWidth
|
||||
if wordsNeeded > 2 {
|
||||
result.reserveCapacity(wordsNeeded)
|
||||
}
|
||||
while bitsLeft >= Word.bitWidth {
|
||||
result[i] = generator.next()
|
||||
i += 1
|
||||
bitsLeft -= Word.bitWidth
|
||||
}
|
||||
if bitsLeft > 0 {
|
||||
let mask: Word = (1 << bitsLeft) - 1
|
||||
result[i] = (generator.next() as Word) & mask
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a big unsigned integer consisting of `width` uniformly distributed random bits.
|
||||
///
|
||||
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
|
||||
///
|
||||
/// - Parameter width: The maximum number of one bits in the result.
|
||||
/// - Returns: A big unsigned integer less than `1 << width`.
|
||||
public static func randomInteger(withMaximumWidth width: Int) -> BigUInt {
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
return randomInteger(withMaximumWidth: width, using: &rng)
|
||||
}
|
||||
|
||||
/// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit.
|
||||
///
|
||||
/// - Note: If `width` is zero, the result is zero.
|
||||
///
|
||||
/// - Parameter width: The number of bits required to represent the answer.
|
||||
/// - Parameter generator: The source of randomness.
|
||||
/// - Returns: A random big unsigned integer whose width is `width`.
|
||||
public static func randomInteger<RNG: RandomNumberGenerator>(withExactWidth width: Int, using generator: inout RNG) -> BigUInt {
|
||||
// width == 0 -> return 0 because there is no room for a one bit.
|
||||
// width == 1 -> return 1 because there is no room for any random bits.
|
||||
guard width > 1 else { return BigUInt(width) }
|
||||
var result = randomInteger(withMaximumWidth: width - 1, using: &generator)
|
||||
result[(width - 1) / Word.bitWidth] |= 1 << Word((width - 1) % Word.bitWidth)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit.
|
||||
///
|
||||
/// - Note: If `width` is zero, the result is zero.
|
||||
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
|
||||
///
|
||||
/// - Returns: A random big unsigned integer whose width is `width`.
|
||||
public static func randomInteger(withExactWidth width: Int) -> BigUInt {
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
return randomInteger(withExactWidth: width, using: &rng)
|
||||
}
|
||||
|
||||
/// Create a uniformly distributed random unsigned integer that's less than the specified limit.
|
||||
///
|
||||
/// - Precondition: `limit > 0`.
|
||||
///
|
||||
/// - Parameter limit: The upper bound on the result.
|
||||
/// - Parameter generator: The source of randomness.
|
||||
/// - Returns: A random big unsigned integer that is less than `limit`.
|
||||
public static func randomInteger<RNG: RandomNumberGenerator>(lessThan limit: BigUInt, using generator: inout RNG) -> BigUInt {
|
||||
precondition(limit > 0, "\(#function): 0 is not a valid limit")
|
||||
let width = limit.bitWidth
|
||||
var random = randomInteger(withMaximumWidth: width, using: &generator)
|
||||
while random >= limit {
|
||||
random = randomInteger(withMaximumWidth: width, using: &generator)
|
||||
}
|
||||
return random
|
||||
}
|
||||
|
||||
/// Create a uniformly distributed random unsigned integer that's less than the specified limit.
|
||||
///
|
||||
/// - Precondition: `limit > 0`.
|
||||
/// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness.
|
||||
///
|
||||
/// - Parameter limit: The upper bound on the result.
|
||||
/// - Returns: A random big unsigned integer that is less than `limit`.
|
||||
public static func randomInteger(lessThan limit: BigUInt) -> BigUInt {
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
return randomInteger(lessThan: limit, using: &rng)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// Shifts.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
|
||||
//MARK: Shift Operators
|
||||
|
||||
internal func shiftedLeft(by amount: Word) -> BigUInt {
|
||||
guard amount > 0 else { return self }
|
||||
|
||||
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
|
||||
let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
|
||||
let down = Word(Word.bitWidth) - up
|
||||
|
||||
var result = BigUInt()
|
||||
if up > 0 {
|
||||
var i = 0
|
||||
var lowbits: Word = 0
|
||||
while i < self.count || lowbits > 0 {
|
||||
let word = self[i]
|
||||
result[i + ext] = word << up | lowbits
|
||||
lowbits = word >> down
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
else {
|
||||
for i in 0 ..< self.count {
|
||||
result[i + ext] = self[i]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal mutating func shiftLeft(by amount: Word) {
|
||||
guard amount > 0 else { return }
|
||||
|
||||
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
|
||||
let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
|
||||
let down = Word(Word.bitWidth) - up
|
||||
|
||||
if up > 0 {
|
||||
var i = 0
|
||||
var lowbits: Word = 0
|
||||
while i < self.count || lowbits > 0 {
|
||||
let word = self[i]
|
||||
self[i] = word << up | lowbits
|
||||
lowbits = word >> down
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
if ext > 0 && self.count > 0 {
|
||||
self.shiftLeft(byWords: ext)
|
||||
}
|
||||
}
|
||||
|
||||
internal func shiftedRight(by amount: Word) -> BigUInt {
|
||||
guard amount > 0 else { return self }
|
||||
guard amount < self.bitWidth else { return 0 }
|
||||
|
||||
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
|
||||
let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
|
||||
let up = Word(Word.bitWidth) - down
|
||||
|
||||
var result = BigUInt()
|
||||
if down > 0 {
|
||||
var highbits: Word = 0
|
||||
for i in (ext ..< self.count).reversed() {
|
||||
let word = self[i]
|
||||
result[i - ext] = highbits | word >> down
|
||||
highbits = word << up
|
||||
}
|
||||
}
|
||||
else {
|
||||
for i in (ext ..< self.count).reversed() {
|
||||
result[i - ext] = self[i]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
internal mutating func shiftRight(by amount: Word) {
|
||||
guard amount > 0 else { return }
|
||||
guard amount < self.bitWidth else { self.clear(); return }
|
||||
|
||||
let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words)
|
||||
let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift)
|
||||
let up = Word(Word.bitWidth) - down
|
||||
|
||||
if ext > 0 {
|
||||
self.shiftRight(byWords: ext)
|
||||
}
|
||||
if down > 0 {
|
||||
var i = self.count - 1
|
||||
var highbits: Word = 0
|
||||
while i >= 0 {
|
||||
let word = self[i]
|
||||
self[i] = highbits | word >> down
|
||||
highbits = word << up
|
||||
i -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func >>=<Other: BinaryInteger>(lhs: inout BigUInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs <<= (0 - rhs)
|
||||
}
|
||||
else if rhs >= lhs.bitWidth {
|
||||
lhs.clear()
|
||||
}
|
||||
else {
|
||||
lhs.shiftRight(by: UInt(rhs))
|
||||
}
|
||||
}
|
||||
|
||||
public static func <<=<Other: BinaryInteger>(lhs: inout BigUInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs >>= (0 - rhs)
|
||||
return
|
||||
}
|
||||
lhs.shiftLeft(by: Word(exactly: rhs)!)
|
||||
}
|
||||
|
||||
public static func >><Other: BinaryInteger>(lhs: BigUInt, rhs: Other) -> BigUInt {
|
||||
if rhs < (0 as Other) {
|
||||
return lhs << (0 - rhs)
|
||||
}
|
||||
if rhs > Word.max {
|
||||
return 0
|
||||
}
|
||||
return lhs.shiftedRight(by: UInt(rhs))
|
||||
}
|
||||
|
||||
public static func <<<Other: BinaryInteger>(lhs: BigUInt, rhs: Other) -> BigUInt {
|
||||
if rhs < (0 as Other) {
|
||||
return lhs >> (0 - rhs)
|
||||
}
|
||||
return lhs.shiftedLeft(by: Word(exactly: rhs)!)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
func shiftedLeft(by amount: Word) -> BigInt {
|
||||
return BigInt(sign: self.sign, magnitude: self.magnitude.shiftedLeft(by: amount))
|
||||
}
|
||||
|
||||
mutating func shiftLeft(by amount: Word) {
|
||||
self.magnitude.shiftLeft(by: amount)
|
||||
}
|
||||
|
||||
func shiftedRight(by amount: Word) -> BigInt {
|
||||
let m = self.magnitude.shiftedRight(by: amount)
|
||||
return BigInt(sign: self.sign, magnitude: self.sign == .minus && m.isZero ? 1 : m)
|
||||
}
|
||||
|
||||
mutating func shiftRight(by amount: Word) {
|
||||
magnitude.shiftRight(by: amount)
|
||||
if sign == .minus, magnitude.isZero {
|
||||
magnitude.load(1)
|
||||
}
|
||||
}
|
||||
|
||||
public static func &<<(left: BigInt, right: BigInt) -> BigInt {
|
||||
return left.shiftedLeft(by: right.words[0])
|
||||
}
|
||||
|
||||
public static func &<<=(left: inout BigInt, right: BigInt) {
|
||||
left.shiftLeft(by: right.words[0])
|
||||
}
|
||||
|
||||
public static func &>>(left: BigInt, right: BigInt) -> BigInt {
|
||||
return left.shiftedRight(by: right.words[0])
|
||||
}
|
||||
|
||||
public static func &>>=(left: inout BigInt, right: BigInt) {
|
||||
left.shiftRight(by: right.words[0])
|
||||
}
|
||||
|
||||
public static func <<<Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
|
||||
guard rhs >= (0 as Other) else { return lhs >> (0 - rhs) }
|
||||
return lhs.shiftedLeft(by: Word(rhs))
|
||||
}
|
||||
|
||||
public static func <<=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs >>= (0 - rhs)
|
||||
}
|
||||
else {
|
||||
lhs.shiftLeft(by: Word(rhs))
|
||||
}
|
||||
}
|
||||
|
||||
public static func >><Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
|
||||
guard rhs >= (0 as Other) else { return lhs << (0 - rhs) }
|
||||
return lhs.shiftedRight(by: Word(rhs))
|
||||
}
|
||||
|
||||
public static func >>=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs <<= (0 - rhs)
|
||||
}
|
||||
else {
|
||||
lhs.shiftRight(by: Word(rhs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Square Root.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
//MARK: Square Root
|
||||
|
||||
extension BigUInt {
|
||||
/// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`.
|
||||
///
|
||||
/// - Returns: floor(sqrt(self))
|
||||
public func squareRoot() -> BigUInt {
|
||||
// This implementation uses Newton's method.
|
||||
guard !self.isZero else { return BigUInt() }
|
||||
var x = BigUInt(1) << ((self.bitWidth + 1) / 2)
|
||||
var y: BigUInt = 0
|
||||
while true {
|
||||
y.load(self)
|
||||
y /= x
|
||||
y += x
|
||||
y >>= 1
|
||||
if x == y || x == y - 1 { break }
|
||||
x = y
|
||||
}
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`.
|
||||
///
|
||||
/// - Requires: self >= 0
|
||||
/// - Returns: floor(sqrt(self))
|
||||
public func squareRoot() -> BigInt {
|
||||
precondition(self.sign == .plus)
|
||||
return BigInt(sign: .plus, magnitude: self.magnitude.squareRoot())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Strideable.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2017-08-11.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt: Strideable {
|
||||
/// A type that can represent the distance between two values ofa `BigUInt`.
|
||||
public typealias Stride = BigInt
|
||||
|
||||
/// Adds `n` to `self` and returns the result. Traps if the result would be less than zero.
|
||||
public func advanced(by n: BigInt) -> BigUInt {
|
||||
return n.sign == .minus ? self - n.magnitude : self + n.magnitude
|
||||
}
|
||||
|
||||
/// Returns the (potentially negative) difference between `self` and `other` as a `BigInt`. Never traps.
|
||||
public func distance(to other: BigUInt) -> BigInt {
|
||||
return BigInt(other) - BigInt(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: Strideable {
|
||||
public typealias Stride = BigInt
|
||||
|
||||
/// Returns `self + n`.
|
||||
public func advanced(by n: Stride) -> BigInt {
|
||||
return self + n
|
||||
}
|
||||
|
||||
/// Returns `other - self`.
|
||||
public func distance(to other: BigInt) -> Stride {
|
||||
return other - self
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// String Conversion.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
|
||||
//MARK: String Conversion
|
||||
|
||||
/// Calculates the number of numerals in a given radix that fit inside a single `Word`.
|
||||
///
|
||||
/// - Returns: (chars, power) where `chars` is highest that satisfy `radix^chars <= 2^Word.bitWidth`. `power` is zero
|
||||
/// if radix is a power of two; otherwise `power == radix^chars`.
|
||||
fileprivate static func charsPerWord(forRadix radix: Int) -> (chars: Int, power: Word) {
|
||||
var power: Word = 1
|
||||
var overflow = false
|
||||
var count = 0
|
||||
while !overflow {
|
||||
let (high,low) = power.multipliedFullWidth(by: Word(radix))
|
||||
if high > 0 {
|
||||
overflow = true
|
||||
}
|
||||
|
||||
if !overflow || (high == 1 && low == 0) {
|
||||
count += 1
|
||||
power = low
|
||||
}
|
||||
}
|
||||
return (count, power)
|
||||
}
|
||||
|
||||
/// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by
|
||||
/// letters from the English alphabet.
|
||||
///
|
||||
/// - Requires: `radix > 1 && radix < 36`
|
||||
/// - Parameter `text`: A string consisting of characters corresponding to numerals in the given radix. (0-9, a-z, A-Z)
|
||||
/// - Parameter `radix`: The base of the number system to use, or 10 if unspecified.
|
||||
/// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`.
|
||||
public init?<S: StringProtocol>(_ text: S, radix: Int = 10) {
|
||||
precondition(radix > 1 && radix < 36)
|
||||
guard !text.isEmpty else { return nil }
|
||||
let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix)
|
||||
|
||||
var words: [Word] = []
|
||||
var end = text.endIndex
|
||||
var start = end
|
||||
var count = 0
|
||||
while start != text.startIndex {
|
||||
start = text.index(before: start)
|
||||
count += 1
|
||||
if count == charsPerWord {
|
||||
guard let d = Word.init(text[start ..< end], radix: radix) else { return nil }
|
||||
words.append(d)
|
||||
end = start
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
if start != end {
|
||||
guard let d = Word.init(text[start ..< end], radix: radix) else { return nil }
|
||||
words.append(d)
|
||||
}
|
||||
|
||||
if power == 0 {
|
||||
self.init(words: words)
|
||||
}
|
||||
else {
|
||||
self.init()
|
||||
for d in words.reversed() {
|
||||
self.multiply(byWord: power)
|
||||
self.addWord(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
/// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by
|
||||
/// letters from the English alphabet.
|
||||
///
|
||||
/// - Requires: `radix > 1 && radix < 36`
|
||||
/// - Parameter `text`: A string optionally starting with "-" or "+" followed by characters corresponding to numerals in the given radix. (0-9, a-z, A-Z)
|
||||
/// - Parameter `radix`: The base of the number system to use, or 10 if unspecified.
|
||||
/// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`.
|
||||
public init?<S: StringProtocol>(_ text: S, radix: Int = 10) {
|
||||
var magnitude: BigUInt?
|
||||
var sign: Sign = .plus
|
||||
if text.first == "-" {
|
||||
sign = .minus
|
||||
let text = text.dropFirst()
|
||||
magnitude = BigUInt(text, radix: radix)
|
||||
}
|
||||
else if text.first == "+" {
|
||||
let text = text.dropFirst()
|
||||
magnitude = BigUInt(text, radix: radix)
|
||||
}
|
||||
else {
|
||||
magnitude = BigUInt(text, radix: radix)
|
||||
}
|
||||
guard let m = magnitude else { return nil }
|
||||
self.magnitude = m
|
||||
self.sign = m.isZero ? .plus : sign
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// Initialize a new string with the base-10 representation of an unsigned big integer.
|
||||
///
|
||||
/// - Complexity: O(v.count^2)
|
||||
public init(_ v: BigUInt) { self.init(v, radix: 10, uppercase: false) }
|
||||
|
||||
/// Initialize a new string representing an unsigned big integer in the given radix (base).
|
||||
///
|
||||
/// Numerals greater than 9 are represented as letters from the English alphabet,
|
||||
/// starting with `a` if `uppercase` is false or `A` otherwise.
|
||||
///
|
||||
/// - Requires: radix > 1 && radix <= 36
|
||||
/// - Complexity: O(count) when radix is a power of two; otherwise O(count^2).
|
||||
public init(_ v: BigUInt, radix: Int, uppercase: Bool = false) {
|
||||
precondition(radix > 1)
|
||||
let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix)
|
||||
|
||||
guard !v.isZero else { self = "0"; return }
|
||||
|
||||
var parts: [String]
|
||||
if power == 0 {
|
||||
parts = v.words.map { String($0, radix: radix, uppercase: uppercase) }
|
||||
}
|
||||
else {
|
||||
parts = []
|
||||
var rest = v
|
||||
while !rest.isZero {
|
||||
let mod = rest.divide(byWord: power)
|
||||
parts.append(String(mod, radix: radix, uppercase: uppercase))
|
||||
}
|
||||
}
|
||||
assert(!parts.isEmpty)
|
||||
|
||||
self = ""
|
||||
var first = true
|
||||
for part in parts.reversed() {
|
||||
let zeroes = charsPerWord - part.count
|
||||
assert(zeroes >= 0)
|
||||
if !first && zeroes > 0 {
|
||||
// Insert leading zeroes for mid-Words
|
||||
self += String(repeating: "0", count: zeroes)
|
||||
}
|
||||
first = false
|
||||
self += part
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize a new string representing a signed big integer in the given radix (base).
|
||||
///
|
||||
/// Numerals greater than 9 are represented as letters from the English alphabet,
|
||||
/// starting with `a` if `uppercase` is false or `A` otherwise.
|
||||
///
|
||||
/// - Requires: radix > 1 && radix <= 36
|
||||
/// - Complexity: O(count) when radix is a power of two; otherwise O(count^2).
|
||||
public init(_ value: BigInt, radix: Int = 10, uppercase: Bool = false) {
|
||||
self = String(value.magnitude, radix: radix, uppercase: uppercase)
|
||||
if value.sign == .minus {
|
||||
self = "-" + self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: ExpressibleByStringLiteral {
|
||||
/// Initialize a new big integer from a Unicode scalar.
|
||||
/// The scalar must represent a decimal digit.
|
||||
public init(unicodeScalarLiteral value: UnicodeScalar) {
|
||||
self = BigUInt(String(value), radix: 10)!
|
||||
}
|
||||
|
||||
/// Initialize a new big integer from an extended grapheme cluster.
|
||||
/// The cluster must consist of a decimal digit.
|
||||
public init(extendedGraphemeClusterLiteral value: String) {
|
||||
self = BigUInt(value, radix: 10)!
|
||||
}
|
||||
|
||||
/// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length.
|
||||
/// The string must contain only decimal digits.
|
||||
public init(stringLiteral value: StringLiteralType) {
|
||||
self = BigUInt(value, radix: 10)!
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: ExpressibleByStringLiteral {
|
||||
/// Initialize a new big integer from a Unicode scalar.
|
||||
/// The scalar must represent a decimal digit.
|
||||
public init(unicodeScalarLiteral value: UnicodeScalar) {
|
||||
self = BigInt(String(value), radix: 10)!
|
||||
}
|
||||
|
||||
/// Initialize a new big integer from an extended grapheme cluster.
|
||||
/// The cluster must consist of a decimal digit.
|
||||
public init(extendedGraphemeClusterLiteral value: String) {
|
||||
self = BigInt(value, radix: 10)!
|
||||
}
|
||||
|
||||
/// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length.
|
||||
/// The string must contain only decimal digits.
|
||||
public init(stringLiteral value: StringLiteralType) {
|
||||
self = BigInt(value, radix: 10)!
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: CustomStringConvertible {
|
||||
/// Return the decimal representation of this integer.
|
||||
public var description: String {
|
||||
return String(self, radix: 10)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: CustomStringConvertible {
|
||||
/// Return the decimal representation of this integer.
|
||||
public var description: String {
|
||||
return String(self, radix: 10)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: CustomDebugStringConvertible {
|
||||
/// Return the decimal representation of this integer.
|
||||
public var debugDescription: String {
|
||||
let text = String(self)
|
||||
return text + " (\(self.bitWidth) bits)"
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: CustomDebugStringConvertible {
|
||||
/// Return the decimal representation of this integer.
|
||||
public var debugDescription: String {
|
||||
let text = String(self)
|
||||
return text + " (\(self.magnitude.bitWidth) bits)"
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt: CustomPlaygroundDisplayConvertible {
|
||||
|
||||
/// Return the playground quick look representation of this integer.
|
||||
public var playgroundDescription: Any {
|
||||
debugDescription
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt: CustomPlaygroundDisplayConvertible {
|
||||
|
||||
/// Return the playground quick look representation of this integer.
|
||||
public var playgroundDescription: Any {
|
||||
debugDescription
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// Subtraction.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2016-01-03.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension BigUInt {
|
||||
//MARK: Subtraction
|
||||
|
||||
/// Subtract `word` from this integer in place, returning a flag indicating if the operation
|
||||
/// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted.
|
||||
///
|
||||
/// - Note: If the result indicates an overflow, then `self` becomes the two's complement of the absolute difference.
|
||||
/// - Complexity: O(count)
|
||||
internal mutating func subtractWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> Bool {
|
||||
precondition(shift >= 0)
|
||||
var carry: Word = word
|
||||
var i = shift
|
||||
let count = self.count
|
||||
while carry > 0 && i < count {
|
||||
let (d, c) = self[i].subtractingReportingOverflow(carry)
|
||||
self[i] = d
|
||||
carry = (c ? 1 : 0)
|
||||
i += 1
|
||||
}
|
||||
return carry > 0
|
||||
}
|
||||
|
||||
/// Subtract `word` from this integer, returning the difference and a flag that is true if the operation
|
||||
/// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted.
|
||||
///
|
||||
/// - Note: If `overflow` is true, then the returned value is the two's complement of the absolute difference.
|
||||
/// - Complexity: O(count)
|
||||
internal func subtractingWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> (partialValue: BigUInt, overflow: Bool) {
|
||||
var result = self
|
||||
let overflow = result.subtractWordReportingOverflow(word, shiftedBy: shift)
|
||||
return (result, overflow)
|
||||
}
|
||||
|
||||
/// Subtract a digit `d` from this integer in place.
|
||||
/// `d` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Requires: self >= d * 2^shift
|
||||
/// - Complexity: O(count)
|
||||
internal mutating func subtractWord(_ word: Word, shiftedBy shift: Int = 0) {
|
||||
let overflow = subtractWordReportingOverflow(word, shiftedBy: shift)
|
||||
precondition(!overflow)
|
||||
}
|
||||
|
||||
/// Subtract a digit `d` from this integer and return the result.
|
||||
/// `d` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Requires: self >= d * 2^shift
|
||||
/// - Complexity: O(count)
|
||||
internal func subtractingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt {
|
||||
var result = self
|
||||
result.subtractWord(word, shiftedBy: shift)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Subtract `other` from this integer in place, and return a flag indicating if the operation caused an
|
||||
/// arithmetic overflow. `other` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Note: If the result indicates an overflow, then `self` becomes the twos' complement of the absolute difference.
|
||||
/// - Complexity: O(count)
|
||||
public mutating func subtractReportingOverflow(_ b: BigUInt, shiftedBy shift: Int = 0) -> Bool {
|
||||
precondition(shift >= 0)
|
||||
var carry = false
|
||||
var bi = 0
|
||||
let bc = b.count
|
||||
let count = self.count
|
||||
while bi < bc || (shift + bi < count && carry) {
|
||||
let ai = shift + bi
|
||||
let (d, c) = self[ai].subtractingReportingOverflow(b[bi])
|
||||
if carry {
|
||||
let (d2, c2) = d.subtractingReportingOverflow(1)
|
||||
self[ai] = d2
|
||||
carry = c || c2
|
||||
}
|
||||
else {
|
||||
self[ai] = d
|
||||
carry = c
|
||||
}
|
||||
bi += 1
|
||||
}
|
||||
return carry
|
||||
}
|
||||
|
||||
/// Subtract `other` from this integer, returning the difference and a flag indicating arithmetic overflow.
|
||||
/// `other` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Note: If `overflow` is true, then the result value is the twos' complement of the absolute value of the difference.
|
||||
/// - Complexity: O(count)
|
||||
public func subtractingReportingOverflow(_ other: BigUInt, shiftedBy shift: Int) -> (partialValue: BigUInt, overflow: Bool) {
|
||||
var result = self
|
||||
let overflow = result.subtractReportingOverflow(other, shiftedBy: shift)
|
||||
return (result, overflow)
|
||||
}
|
||||
|
||||
/// Subtracts `other` from `self`, returning the result and a flag indicating arithmetic overflow.
|
||||
///
|
||||
/// - Note: When the operation overflows, then `partialValue` is the twos' complement of the absolute value of the difference.
|
||||
/// - Complexity: O(count)
|
||||
public func subtractingReportingOverflow(_ other: BigUInt) -> (partialValue: BigUInt, overflow: Bool) {
|
||||
return self.subtractingReportingOverflow(other, shiftedBy: 0)
|
||||
}
|
||||
|
||||
/// Subtract `other` from this integer in place.
|
||||
/// `other` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Requires: self >= other * 2^shift
|
||||
/// - Complexity: O(count)
|
||||
public mutating func subtract(_ other: BigUInt, shiftedBy shift: Int = 0) {
|
||||
let overflow = subtractReportingOverflow(other, shiftedBy: shift)
|
||||
precondition(!overflow)
|
||||
}
|
||||
|
||||
/// Subtract `b` from this integer, and return the difference.
|
||||
/// `b` is shifted `shift` digits to the left before being subtracted.
|
||||
///
|
||||
/// - Requires: self >= b * 2^shift
|
||||
/// - Complexity: O(count)
|
||||
public func subtracting(_ other: BigUInt, shiftedBy shift: Int = 0) -> BigUInt {
|
||||
var result = self
|
||||
result.subtract(other, shiftedBy: shift)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrement this integer by one.
|
||||
///
|
||||
/// - Requires: !isZero
|
||||
/// - Complexity: O(count)
|
||||
public mutating func decrement(shiftedBy shift: Int = 0) {
|
||||
self.subtract(1, shiftedBy: shift)
|
||||
}
|
||||
|
||||
/// Subtract `b` from `a` and return the result.
|
||||
///
|
||||
/// - Requires: a >= b
|
||||
/// - Complexity: O(a.count)
|
||||
public static func -(a: BigUInt, b: BigUInt) -> BigUInt {
|
||||
return a.subtracting(b)
|
||||
}
|
||||
|
||||
/// Subtract `b` from `a` and store the result in `a`.
|
||||
///
|
||||
/// - Requires: a >= b
|
||||
/// - Complexity: O(a.count)
|
||||
public static func -=(a: inout BigUInt, b: BigUInt) {
|
||||
a.subtract(b)
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public mutating func negate() {
|
||||
guard !magnitude.isZero else { return }
|
||||
self.sign = self.sign == .plus ? .minus : .plus
|
||||
}
|
||||
|
||||
/// Subtract `b` from `a` and return the result.
|
||||
public static func -(a: BigInt, b: BigInt) -> BigInt {
|
||||
return a + -b
|
||||
}
|
||||
|
||||
/// Subtract `b` from `a` in place.
|
||||
public static func -=(a: inout BigInt, b: BigInt) { a = a - b }
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//
|
||||
// Words and Bits.swift
|
||||
// BigInt
|
||||
//
|
||||
// Created by Károly Lőrentey on 2017-08-11.
|
||||
// Copyright © 2016-2017 Károly Lőrentey.
|
||||
//
|
||||
|
||||
extension Array where Element == UInt {
|
||||
mutating func twosComplement() {
|
||||
var increment = true
|
||||
for i in 0 ..< self.count {
|
||||
if increment {
|
||||
(self[i], increment) = (~self[i]).addingReportingOverflow(1)
|
||||
}
|
||||
else {
|
||||
self[i] = ~self[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
public subscript(bitAt index: Int) -> Bool {
|
||||
get {
|
||||
precondition(index >= 0)
|
||||
let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth)
|
||||
return self[i] & (1 << j) != 0
|
||||
}
|
||||
set {
|
||||
precondition(index >= 0)
|
||||
let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth)
|
||||
if newValue {
|
||||
self[i] |= 1 << j
|
||||
}
|
||||
else {
|
||||
self[i] &= ~(1 << j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
/// The minimum number of bits required to represent this integer in binary.
|
||||
///
|
||||
/// - Returns: floor(log2(2 * self + 1))
|
||||
/// - Complexity: O(1)
|
||||
public var bitWidth: Int {
|
||||
guard count > 0 else { return 0 }
|
||||
return count * Word.bitWidth - self[count - 1].leadingZeroBitCount
|
||||
}
|
||||
|
||||
/// The number of leading zero bits in the binary representation of this integer in base `2^(Word.bitWidth)`.
|
||||
/// This is useful when you need to normalize a `BigUInt` such that the top bit of its most significant word is 1.
|
||||
///
|
||||
/// - Note: 0 is considered to have zero leading zero bits.
|
||||
/// - Returns: A value in `0...(Word.bitWidth - 1)`.
|
||||
/// - SeeAlso: width
|
||||
/// - Complexity: O(1)
|
||||
public var leadingZeroBitCount: Int {
|
||||
guard count > 0 else { return 0 }
|
||||
return self[count - 1].leadingZeroBitCount
|
||||
}
|
||||
|
||||
/// The number of trailing zero bits in the binary representation of this integer.
|
||||
///
|
||||
/// - Note: 0 is considered to have zero trailing zero bits.
|
||||
/// - Returns: A value in `0...width`.
|
||||
/// - Complexity: O(count)
|
||||
public var trailingZeroBitCount: Int {
|
||||
guard count > 0 else { return 0 }
|
||||
let i = self.words.firstIndex { $0 != 0 }!
|
||||
return i * Word.bitWidth + self[i].trailingZeroBitCount
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public var bitWidth: Int {
|
||||
guard !magnitude.isZero else { return 0 }
|
||||
return magnitude.bitWidth + 1
|
||||
}
|
||||
|
||||
public var trailingZeroBitCount: Int {
|
||||
// Amazingly, this works fine for negative numbers
|
||||
return magnitude.trailingZeroBitCount
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
public struct Words: RandomAccessCollection {
|
||||
private let value: BigUInt
|
||||
|
||||
fileprivate init(_ value: BigUInt) { self.value = value }
|
||||
|
||||
public var startIndex: Int { return 0 }
|
||||
public var endIndex: Int { return value.count }
|
||||
|
||||
public subscript(_ index: Int) -> Word {
|
||||
return value[index]
|
||||
}
|
||||
}
|
||||
|
||||
public var words: Words { return Words(self) }
|
||||
|
||||
public init<Words: Sequence>(words: Words) where Words.Element == Word {
|
||||
let uc = words.underestimatedCount
|
||||
if uc > 2 {
|
||||
self.init(words: Array(words))
|
||||
}
|
||||
else {
|
||||
var it = words.makeIterator()
|
||||
guard let w0 = it.next() else {
|
||||
self.init()
|
||||
return
|
||||
}
|
||||
guard let w1 = it.next() else {
|
||||
self.init(word: w0)
|
||||
return
|
||||
}
|
||||
if let w2 = it.next() {
|
||||
var words: [UInt] = []
|
||||
words.reserveCapacity(Swift.max(3, uc))
|
||||
words.append(w0)
|
||||
words.append(w1)
|
||||
words.append(w2)
|
||||
while let word = it.next() {
|
||||
words.append(word)
|
||||
}
|
||||
self.init(words: words)
|
||||
}
|
||||
else {
|
||||
self.init(low: w0, high: w1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BigInt {
|
||||
public struct Words: RandomAccessCollection {
|
||||
public typealias Indices = CountableRange<Int>
|
||||
|
||||
private let value: BigInt
|
||||
private let decrementLimit: Int
|
||||
|
||||
fileprivate init(_ value: BigInt) {
|
||||
self.value = value
|
||||
switch value.sign {
|
||||
case .plus:
|
||||
self.decrementLimit = 0
|
||||
case .minus:
|
||||
assert(!value.magnitude.isZero)
|
||||
self.decrementLimit = value.magnitude.words.firstIndex(where: { $0 != 0 })!
|
||||
}
|
||||
}
|
||||
|
||||
public var count: Int {
|
||||
switch value.sign {
|
||||
case .plus:
|
||||
if let high = value.magnitude.words.last, high >> (Word.bitWidth - 1) != 0 {
|
||||
return value.magnitude.count + 1
|
||||
}
|
||||
return value.magnitude.count
|
||||
case .minus:
|
||||
let high = value.magnitude.words.last!
|
||||
if high >> (Word.bitWidth - 1) != 0 {
|
||||
return value.magnitude.count + 1
|
||||
}
|
||||
return value.magnitude.count
|
||||
}
|
||||
}
|
||||
|
||||
public var indices: Indices { return 0 ..< count }
|
||||
public var startIndex: Int { return 0 }
|
||||
public var endIndex: Int { return count }
|
||||
|
||||
public subscript(_ index: Int) -> UInt {
|
||||
// Note that indices above `endIndex` are accepted.
|
||||
if value.sign == .plus {
|
||||
return value.magnitude[index]
|
||||
}
|
||||
if index <= decrementLimit {
|
||||
return ~(value.magnitude[index] &- 1)
|
||||
}
|
||||
return ~value.magnitude[index]
|
||||
}
|
||||
}
|
||||
|
||||
public var words: Words {
|
||||
return Words(self)
|
||||
}
|
||||
|
||||
public init<S: Sequence>(words: S) where S.Element == Word {
|
||||
var words = Array(words)
|
||||
if (words.last ?? 0) >> (Word.bitWidth - 1) == 0 {
|
||||
self.init(sign: .plus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
else {
|
||||
words.twosComplement()
|
||||
self.init(sign: .minus, magnitude: BigUInt(words: words))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// Models.swift
|
||||
// PortalKit
|
||||
//
|
||||
// Data types and result models for PortalClient operations.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PortalClientError: LocalizedError, Sendable {
|
||||
case invalidHost(String)
|
||||
case invalidURL(String)
|
||||
case pairingFailed(String)
|
||||
case notPaired
|
||||
case tlsPinningMismatch(expected: String, got: String)
|
||||
case requestFailed(statusCode: Int, message: String)
|
||||
case communicationError(String)
|
||||
case jsonParsingError(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHost(let h): return "Invalid Portal host: \(h)"
|
||||
case .invalidURL(let u): return "Invalid URL: \(u)"
|
||||
case .pairingFailed(let m): return "Pairing failed: \(m)"
|
||||
case .notPaired: return "Portal is not paired. Please pair first or specify credentials."
|
||||
case .tlsPinningMismatch:
|
||||
return "Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal."
|
||||
case .requestFailed(let code, let msg):
|
||||
return "HTTP request failed with status \(code): \(msg)"
|
||||
case .communicationError(let msg):
|
||||
return "Network communication error: \(msg)"
|
||||
case .jsonParsingError(let msg):
|
||||
return "JSON parsing error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct PortalStatus: Sendable {
|
||||
public let host: String
|
||||
public let isOnline: Bool
|
||||
public let isPaired: Bool
|
||||
public let pinnedCertSha256: String?
|
||||
public let leafCertSha256: String?
|
||||
public let isCertPinMatching: Bool
|
||||
public let serverReportedCertSha256: String?
|
||||
public let details: String
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
isOnline: Bool,
|
||||
isPaired: Bool,
|
||||
pinnedCertSha256: String?,
|
||||
leafCertSha256: String?,
|
||||
isCertPinMatching: Bool,
|
||||
serverReportedCertSha256: String?,
|
||||
details: String
|
||||
) {
|
||||
self.host = host
|
||||
self.isOnline = isOnline
|
||||
self.isPaired = isPaired
|
||||
self.pinnedCertSha256 = pinnedCertSha256
|
||||
self.leafCertSha256 = leafCertSha256
|
||||
self.isCertPinMatching = isCertPinMatching
|
||||
self.serverReportedCertSha256 = serverReportedCertSha256
|
||||
self.details = details
|
||||
}
|
||||
}
|
||||
|
||||
/// Camera mode + mode-specific config from `/control/state` and SSE `/control/events`.
|
||||
public struct PortalCameraState: Codable, Sendable, Equatable {
|
||||
public let mode: String
|
||||
public let config: PortalCameraConfig
|
||||
|
||||
public init(mode: String, config: PortalCameraConfig = PortalCameraConfig()) {
|
||||
self.mode = mode
|
||||
self.config = config
|
||||
}
|
||||
}
|
||||
|
||||
public struct PortalCameraConfig: Codable, Sendable, Equatable {
|
||||
public let centerX: Double?
|
||||
public let centerY: Double?
|
||||
public let scale: Double?
|
||||
public let framingTightness: Double?
|
||||
public let trackingResponseDelayPct: Double?
|
||||
public let trackingSensitivityPct: Double?
|
||||
public let transitionSpeedPct: Double?
|
||||
|
||||
public init(
|
||||
centerX: Double? = nil,
|
||||
centerY: Double? = nil,
|
||||
scale: Double? = nil,
|
||||
framingTightness: Double? = nil,
|
||||
trackingResponseDelayPct: Double? = nil,
|
||||
trackingSensitivityPct: Double? = nil,
|
||||
transitionSpeedPct: Double? = nil
|
||||
) {
|
||||
self.centerX = centerX
|
||||
self.centerY = centerY
|
||||
self.scale = scale
|
||||
self.framingTightness = framingTightness
|
||||
self.trackingResponseDelayPct = trackingResponseDelayPct
|
||||
self.trackingSensitivityPct = trackingSensitivityPct
|
||||
self.transitionSpeedPct = transitionSpeedPct
|
||||
}
|
||||
}
|
||||
|
||||
public struct PairingSession: Sendable {
|
||||
public let pairingId: String
|
||||
public let saltHex: String
|
||||
public let pubBHex: String
|
||||
public let expiresIn: Int?
|
||||
public let capturedCertSha256: Data
|
||||
public let capturedCertSha256Hex: String
|
||||
public let srpClient: PortalSrpClient
|
||||
|
||||
public init(
|
||||
pairingId: String,
|
||||
saltHex: String,
|
||||
pubBHex: String,
|
||||
expiresIn: Int?,
|
||||
capturedCertSha256: Data,
|
||||
capturedCertSha256Hex: String,
|
||||
srpClient: PortalSrpClient
|
||||
) {
|
||||
self.pairingId = pairingId
|
||||
self.saltHex = saltHex
|
||||
self.pubBHex = pubBHex
|
||||
self.expiresIn = expiresIn
|
||||
self.capturedCertSha256 = capturedCertSha256
|
||||
self.capturedCertSha256Hex = capturedCertSha256Hex
|
||||
self.srpClient = srpClient
|
||||
}
|
||||
}
|
||||
|
||||
public struct PairingResult: Sendable {
|
||||
public let success: Bool
|
||||
public let token: String
|
||||
public let pinnedCertSha256Hex: String
|
||||
public let serverM2Hex: String
|
||||
|
||||
public init(success: Bool, token: String, pinnedCertSha256Hex: String, serverM2Hex: String) {
|
||||
self.success = success
|
||||
self.token = token
|
||||
self.pinnedCertSha256Hex = pinnedCertSha256Hex
|
||||
self.serverM2Hex = serverM2Hex
|
||||
}
|
||||
}
|
||||
|
||||
public struct MitmDefenseResult: Sendable {
|
||||
public let attackDescription: String
|
||||
public let simulatedMitmCertSha256Hex: String
|
||||
public let realCertSha256Hex: String
|
||||
public let defenseSuccessful: Bool
|
||||
public let serverRejectionMessage: String
|
||||
public let attemptsRemaining: Int?
|
||||
|
||||
public init(
|
||||
attackDescription: String,
|
||||
simulatedMitmCertSha256Hex: String,
|
||||
realCertSha256Hex: String,
|
||||
defenseSuccessful: Bool,
|
||||
serverRejectionMessage: String,
|
||||
attemptsRemaining: Int?
|
||||
) {
|
||||
self.attackDescription = attackDescription
|
||||
self.simulatedMitmCertSha256Hex = simulatedMitmCertSha256Hex
|
||||
self.realCertSha256Hex = realCertSha256Hex
|
||||
self.defenseSuccessful = defenseSuccessful
|
||||
self.serverRejectionMessage = serverRejectionMessage
|
||||
self.attemptsRemaining = attemptsRemaining
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
//
|
||||
// PortalClient.swift
|
||||
// PortalKit
|
||||
//
|
||||
// High-level client for pairing, status, and control with Portal TV.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public final class PortalClient: @unchecked Sendable {
|
||||
public let host: String
|
||||
public let credentialStorage: CredentialStorage
|
||||
public let requestTimeout: TimeInterval
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
credentialStorage: CredentialStorage = PortalAuth.defaultStorage,
|
||||
requestTimeout: TimeInterval = 10
|
||||
) {
|
||||
// Normalize host: strip leading scheme if present, default PortalCam port if missing
|
||||
var clean = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if clean.hasPrefix("https://") {
|
||||
clean = String(clean.dropFirst("https://".count))
|
||||
} else if clean.hasPrefix("http://") {
|
||||
clean = String(clean.dropFirst("http://".count))
|
||||
}
|
||||
if clean.hasSuffix("/") {
|
||||
clean = String(clean.dropLast())
|
||||
}
|
||||
if !clean.contains(":") {
|
||||
clean = "\(clean):\(PortalEndpoints.port)"
|
||||
}
|
||||
self.host = clean
|
||||
self.credentialStorage = credentialStorage
|
||||
self.requestTimeout = requestTimeout
|
||||
}
|
||||
|
||||
private var baseURLString: String {
|
||||
"https://\(host)"
|
||||
}
|
||||
|
||||
// MARK: - Status & Inspection
|
||||
|
||||
/// Checks service status, queries /auth/cert, and verifies TLS certificate pinning state.
|
||||
public func status() async throws -> PortalStatus {
|
||||
guard let url = URL(string: "\(baseURLString)/auth/cert") else {
|
||||
throw PortalClientError.invalidHost(host)
|
||||
}
|
||||
|
||||
let delegate = SrpPairingSessionDelegate()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = requestTimeout
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
var isOnline = false
|
||||
var serverReportedSha: String?
|
||||
var detailsMsg = ""
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
|
||||
isOnline = true
|
||||
if let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let sha = json["certSha256"] as? String {
|
||||
serverReportedSha = sha.lowercased()
|
||||
}
|
||||
detailsMsg = "Portal TV service online (HTTP 200)"
|
||||
} else {
|
||||
isOnline = true
|
||||
detailsMsg = "Portal TV reached (HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0))"
|
||||
}
|
||||
} catch {
|
||||
detailsMsg = "Connection failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
let leafCertSha = delegate.capturedCertSha256Hex?.lowercased()
|
||||
let pinnedCertSha = credentialStorage.getPinnedCertSha256()?.lowercased()
|
||||
let authToken = credentialStorage.getAuthToken()
|
||||
let isPaired = (authToken != nil && pinnedCertSha != nil)
|
||||
|
||||
let isCertPinMatching: Bool
|
||||
if let pinned = pinnedCertSha, let leaf = leafCertSha {
|
||||
isCertPinMatching = (pinned.caseInsensitiveCompare(leaf) == .orderedSame)
|
||||
} else {
|
||||
isCertPinMatching = false
|
||||
}
|
||||
|
||||
return PortalStatus(
|
||||
host: host,
|
||||
isOnline: isOnline,
|
||||
isPaired: isPaired,
|
||||
pinnedCertSha256: pinnedCertSha,
|
||||
leafCertSha256: leafCertSha,
|
||||
isCertPinMatching: isCertPinMatching,
|
||||
serverReportedCertSha256: serverReportedSha,
|
||||
details: detailsMsg
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pairing Phase
|
||||
|
||||
/// Step 1: Initiate pairing over ephemeral TLS, capturing the certificate digest and SRP parameters.
|
||||
public func initiatePairing() async throws -> PairingSession {
|
||||
guard let url = URL(string: "\(baseURLString)/auth/srp/init") else {
|
||||
throw PortalClientError.invalidHost(host)
|
||||
}
|
||||
|
||||
let delegate = SrpPairingSessionDelegate()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = requestTimeout
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
throw PortalClientError.communicationError("Failed to initiate pairing: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
let msg = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)"
|
||||
throw PortalClientError.pairingFailed("Init rejected by server: \(msg)")
|
||||
}
|
||||
|
||||
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let pairingId = json["pairingId"] as? String,
|
||||
let salt = json["salt"] as? String,
|
||||
let b = json["B"] as? String else {
|
||||
throw PortalClientError.jsonParsingError("Invalid JSON payload from /auth/srp/init")
|
||||
}
|
||||
|
||||
guard let certData = delegate.capturedCertSha256,
|
||||
let certHex = delegate.capturedCertSha256Hex else {
|
||||
throw PortalClientError.pairingFailed("Failed to capture TLS certificate for channel binding")
|
||||
}
|
||||
|
||||
let srpClient = PortalSrpClient()
|
||||
let expiresIn = json["expiresIn"] as? Int
|
||||
|
||||
return PairingSession(
|
||||
pairingId: pairingId,
|
||||
saltHex: salt,
|
||||
pubBHex: b,
|
||||
expiresIn: expiresIn,
|
||||
capturedCertSha256: certData,
|
||||
capturedCertSha256Hex: certHex,
|
||||
srpClient: srpClient
|
||||
)
|
||||
}
|
||||
|
||||
/// Step 2: Complete pairing with user PIN, verifying server M2 and pinning the certificate.
|
||||
public func completePairing(session: PairingSession, pin: String) async throws -> PairingResult {
|
||||
let cleanPin = pin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanPin.isEmpty else {
|
||||
throw PortalClientError.pairingFailed("PIN cannot be empty")
|
||||
}
|
||||
|
||||
let m1Hex: String
|
||||
do {
|
||||
m1Hex = try session.srpClient.computeM1(
|
||||
saltHex: session.saltHex,
|
||||
pubBHex: session.pubBHex,
|
||||
pin: cleanPin,
|
||||
tlsCertSha256: session.capturedCertSha256
|
||||
)
|
||||
} catch {
|
||||
throw PortalClientError.pairingFailed("SRP computation failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else {
|
||||
throw PortalClientError.invalidHost(host)
|
||||
}
|
||||
|
||||
let delegate = SrpPairingSessionDelegate()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = requestTimeout
|
||||
let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let payload: [String: String] = [
|
||||
"pairingId": session.pairingId,
|
||||
"A": session.srpClient.pubAHex,
|
||||
"M1": m1Hex
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await urlSession.data(for: request)
|
||||
} catch {
|
||||
throw PortalClientError.communicationError("Verification request failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let http = response as? HTTPURLResponse
|
||||
guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
||||
let bodyStr = String(data: data, encoding: .utf8) ?? ""
|
||||
throw PortalClientError.pairingFailed("Invalid response from server: \(bodyStr)")
|
||||
}
|
||||
|
||||
if http?.statusCode == 200,
|
||||
let m2Hex = json["M2"] as? String,
|
||||
let token = json["token"] as? String {
|
||||
// Verify server M2
|
||||
do {
|
||||
try session.srpClient.verifyServerM2(serverM2Hex: m2Hex)
|
||||
} catch {
|
||||
throw PortalClientError.pairingFailed("Server proof M2 verification failed! Potential MITM attack: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
credentialStorage.save(token: token, certSha256: session.capturedCertSha256Hex)
|
||||
|
||||
return PairingResult(
|
||||
success: true,
|
||||
token: token,
|
||||
pinnedCertSha256Hex: session.capturedCertSha256Hex,
|
||||
serverM2Hex: m2Hex
|
||||
)
|
||||
} else {
|
||||
let errorMsg = json["message"] as? String ?? json["error"] as? String ?? "Pairing rejected"
|
||||
let attempts = json["attemptsLeft"] as? Int
|
||||
var fullMsg = errorMsg
|
||||
if let a = attempts {
|
||||
fullMsg += " (\(a) attempts remaining)"
|
||||
}
|
||||
throw PortalClientError.pairingFailed(fullMsg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Full pairing flow: initiates pairing, computes M1 with PIN, verifies M2, and saves credentials.
|
||||
public func pair(pin: String) async throws -> PairingResult {
|
||||
let session = try await initiatePairing()
|
||||
return try await completePairing(session: session, pin: pin)
|
||||
}
|
||||
|
||||
// MARK: - Control Commands (Pinned TLS & Bearer Auth)
|
||||
|
||||
/// One-shot camera state (`GET /control/state`). Prefer [cameraStateEvents] for live UI.
|
||||
public func cameraState() async throws -> PortalCameraState {
|
||||
try await controlState(path: "/control/state")
|
||||
}
|
||||
|
||||
/// Live camera state via SSE (`GET /control/events`). Emits initial state, then updates.
|
||||
public func cameraStateEvents() -> AsyncThrowingStream<PortalCameraState, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let task = Task {
|
||||
do {
|
||||
try await self.consumeCameraStateEvents(continuation: continuation)
|
||||
} catch is CancellationError {
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch mode (`DefaultAuto`, `Desk`, `Meeting`, `Fixed`). State arrives via SSE.
|
||||
public func setMode(_ mode: String) async throws {
|
||||
let encoded = mode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mode
|
||||
try await controlAck(path: "/control/mode?mode=\(encoded)")
|
||||
}
|
||||
|
||||
/// Apply Fixed crop. State arrives via SSE.
|
||||
public func setFixedCrop(x: Double, y: Double, scale: Double) async throws {
|
||||
try await controlAck(path: "/control/fixed?x=\(x)&y=\(y)&scale=\(scale)")
|
||||
}
|
||||
|
||||
/// Apply Desk framing tightness. State arrives via SSE.
|
||||
public func setDeskTightness(_ tightness: Double) async throws {
|
||||
try await controlAck(path: "/control/desk?tightness=\(tightness)")
|
||||
}
|
||||
|
||||
/// Sends a control command over pinned HTTPS using stored credentials.
|
||||
/// Prefer typed helpers (`cameraState`, `setMode`, …).
|
||||
@discardableResult
|
||||
public func control(command: String) async throws -> String {
|
||||
let data = try await controlRequest(path: Self.formatControlPath(command))
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
private func controlAck(path: String) async throws {
|
||||
let data = try await controlRequest(path: path)
|
||||
if let state = try? JSONDecoder().decode(PortalCameraState.self, from: data) {
|
||||
// Older servers still return state; ignore body shape either way.
|
||||
_ = state
|
||||
return
|
||||
}
|
||||
// Expect {"ok":true} or tolerate empty/other 200 bodies.
|
||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let err = obj["error"] as? String {
|
||||
let msg = obj["message"] as? String ?? err
|
||||
throw PortalClientError.requestFailed(statusCode: 200, message: msg)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlState(path: String) async throws -> PortalCameraState {
|
||||
let data = try await controlRequest(path: path)
|
||||
do {
|
||||
return try JSONDecoder().decode(PortalCameraState.self, from: data)
|
||||
} catch {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw PortalClientError.jsonParsingError("Expected PortalCameraState, got: \(body)")
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeCameraStateEvents(
|
||||
continuation: AsyncThrowingStream<PortalCameraState, Error>.Continuation
|
||||
) async throws {
|
||||
guard let pinnedCert = credentialStorage.getPinnedCertSha256(),
|
||||
let token = credentialStorage.getAuthToken() else {
|
||||
throw PortalClientError.notPaired
|
||||
}
|
||||
guard let url = URL(string: "\(baseURLString)/control/events") else {
|
||||
throw PortalClientError.invalidURL("\(baseURLString)/control/events")
|
||||
}
|
||||
|
||||
// Same pinning + dataTask path as video/control — `bytes(for:)` can miss
|
||||
// session-level auth challenges and fall through to default (self-signed) trust.
|
||||
let stream = PortalSSEDataStream(url: url, token: token, pinnedFingerprint: pinnedCert)
|
||||
try await withTaskCancellationHandler {
|
||||
try await stream.run { state in
|
||||
continuation.yield(state)
|
||||
}
|
||||
continuation.finish()
|
||||
} onCancel: {
|
||||
stream.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func controlRequest(path: String) async throws -> Data {
|
||||
guard let pinnedCert = credentialStorage.getPinnedCertSha256(),
|
||||
let token = credentialStorage.getAuthToken() else {
|
||||
throw PortalClientError.notPaired
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(baseURLString)\(path)") else {
|
||||
throw PortalClientError.invalidURL("\(baseURLString)\(path)")
|
||||
}
|
||||
|
||||
let delegate = PortalPinnedSessionDelegate(pinnedFingerprint: pinnedCert)
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = requestTimeout
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch {
|
||||
if let mismatch = delegate.consumePinMismatch() {
|
||||
throw PortalClientError.tlsPinningMismatch(expected: mismatch.expected, got: mismatch.got)
|
||||
}
|
||||
throw PortalClientError.communicationError(error.localizedDescription)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw PortalClientError.communicationError("Invalid HTTP response")
|
||||
}
|
||||
|
||||
if http.statusCode == 200 {
|
||||
return data
|
||||
}
|
||||
let bodyString = String(data: data, encoding: .utf8) ?? ""
|
||||
throw PortalClientError.requestFailed(statusCode: http.statusCode, message: bodyString)
|
||||
}
|
||||
|
||||
static func formatControlPath(_ command: String) -> String {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("/control/") || trimmed == "/control" || trimmed.hasPrefix("/control?") {
|
||||
return trimmed
|
||||
} else if trimmed.hasPrefix("/control") {
|
||||
return trimmed
|
||||
} else if trimmed.hasPrefix("control/") {
|
||||
return "/\(trimmed)"
|
||||
} else if trimmed == "state" || trimmed.hasPrefix("state?") {
|
||||
return "/control/\(trimmed)"
|
||||
} else if trimmed.hasPrefix("/") {
|
||||
return "/control\(trimmed)"
|
||||
} else if trimmed.hasPrefix("mode ") {
|
||||
let modeVal = String(trimmed.dropFirst("mode ".count)).trimmingCharacters(in: .whitespaces)
|
||||
return "/control/mode?mode=\(modeVal)"
|
||||
} else if trimmed.hasPrefix("mode?") || trimmed.hasPrefix("fixed?") || trimmed.hasPrefix("desk?") {
|
||||
return "/control/\(trimmed)"
|
||||
} else {
|
||||
return "/control/\(trimmed)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MITM Defense Test
|
||||
|
||||
/// Tests channel binding defense by computing M1 with an altered TLS certificate hash
|
||||
/// and confirming that Portal TV rejects the pairing exchange.
|
||||
public func testMitm(
|
||||
simulatedMitmCertSha256: Data? = nil,
|
||||
pin: String = "123456"
|
||||
) async throws -> MitmDefenseResult {
|
||||
let session = try await initiatePairing()
|
||||
|
||||
let fakeCertHash: Data
|
||||
if let customFake = simulatedMitmCertSha256 {
|
||||
fakeCertHash = customFake
|
||||
} else {
|
||||
// Alter genuine cert hash by bit inversion
|
||||
fakeCertHash = Data(session.capturedCertSha256.map { ~$0 })
|
||||
}
|
||||
let fakeCertHex = SrpFormat.bytesToHex(fakeCertHash)
|
||||
|
||||
// Compute M1 bound to the fake cert
|
||||
let m1Hex = try session.srpClient.computeM1(
|
||||
saltHex: session.saltHex,
|
||||
pubBHex: session.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: fakeCertHash
|
||||
)
|
||||
|
||||
guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else {
|
||||
throw PortalClientError.invalidHost(host)
|
||||
}
|
||||
|
||||
let delegate = SrpPairingSessionDelegate()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = requestTimeout
|
||||
let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let payload: [String: String] = [
|
||||
"pairingId": session.pairingId,
|
||||
"A": session.srpClient.pubAHex,
|
||||
"M1": m1Hex
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
let http = response as? HTTPURLResponse
|
||||
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
|
||||
let isRejected = (http?.statusCode != 200)
|
||||
let errMsg = json?["message"] as? String ?? json?["error"] as? String ?? "HTTP \(http?.statusCode ?? 0)"
|
||||
let attemptsLeft = json?["attemptsLeft"] as? Int
|
||||
|
||||
return MitmDefenseResult(
|
||||
attackDescription: "Simulated rogue MITM TLS certificate substitution",
|
||||
simulatedMitmCertSha256Hex: fakeCertHex,
|
||||
realCertSha256Hex: session.capturedCertSha256Hex,
|
||||
defenseSuccessful: isRejected,
|
||||
serverRejectionMessage: errMsg,
|
||||
attemptsRemaining: attemptsLeft
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// PortalEndpoints.swift
|
||||
// PortalKit
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum PortalEndpoints: Sendable {
|
||||
/// Fixed PortalCam HTTPS / DNS-SD port ("TV" joke → 5654).
|
||||
public static let port: Int = 5654
|
||||
|
||||
/// Bonjour / DNS-SD service type (no trailing dot for NWBrowser).
|
||||
public static let bonjourType = "_portalcam._tcp"
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// PortalSSEDataStream.swift
|
||||
// PortalKit
|
||||
//
|
||||
// Long-lived SSE reader using URLSessionDataDelegate + pin (same path as video).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Streams `text/event-stream` over pinned HTTPS via a classic data task.
|
||||
final class PortalSSEDataStream: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let token: String
|
||||
private let pinnedFingerprint: String
|
||||
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionDataTask?
|
||||
private var buffer = Data()
|
||||
private var pendingDataLines: [String] = []
|
||||
private var lastPinMismatch: (expected: String, got: String)?
|
||||
|
||||
private var onState: ((PortalCameraState) -> Void)?
|
||||
private var finish: ((Result<Void, Error>) -> Void)?
|
||||
private var finished = false
|
||||
private let lock = NSLock()
|
||||
|
||||
init(url: URL, token: String, pinnedFingerprint: String) {
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.pinnedFingerprint = pinnedFingerprint
|
||||
super.init()
|
||||
}
|
||||
|
||||
func run(onState: @escaping (PortalCameraState) -> Void) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.lock.lock()
|
||||
self.onState = onState
|
||||
self.finish = { result in
|
||||
cont.resume(with: result)
|
||||
}
|
||||
self.lock.unlock()
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finishOnce(.failure(CancellationError()))
|
||||
tearDown()
|
||||
}
|
||||
|
||||
private func start() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = .infinity
|
||||
config.timeoutIntervalForResource = .infinity
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
self.session = session
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||
|
||||
let task = session.dataTask(with: request)
|
||||
self.task = task
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private func tearDown() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
session?.invalidateAndCancel()
|
||||
session = nil
|
||||
}
|
||||
|
||||
private func finishOnce(_ result: Result<Void, Error>) {
|
||||
lock.lock()
|
||||
guard !finished else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
let done = finish
|
||||
finish = nil
|
||||
onState = nil
|
||||
lock.unlock()
|
||||
tearDown()
|
||||
done?(result)
|
||||
}
|
||||
|
||||
private func evaluateChallenge(
|
||||
_ challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
PortalTlsPinning.evaluate(
|
||||
challenge: challenge,
|
||||
pinnedFingerprint: pinnedFingerprint
|
||||
) { [weak self] disposition, credential in
|
||||
if disposition == .cancelAuthenticationChallenge,
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust,
|
||||
let cert = PortalTlsPinning.extractLeafCert(from: trust) {
|
||||
let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert)
|
||||
if self?.pinnedFingerprint.caseInsensitiveCompare(got) != .orderedSame {
|
||||
self?.lock.lock()
|
||||
self?.lastPinMismatch = (expected: self?.pinnedFingerprint ?? "", got: got)
|
||||
self?.lock.unlock()
|
||||
}
|
||||
}
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSessionDelegate
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
evaluateChallenge(challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
evaluateChallenge(challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse,
|
||||
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||
) {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
completionHandler(.cancel)
|
||||
finishOnce(.failure(PortalClientError.communicationError("Invalid HTTP response")))
|
||||
return
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
completionHandler(.cancel)
|
||||
finishOnce(.failure(PortalClientError.requestFailed(
|
||||
statusCode: http.statusCode,
|
||||
message: "SSE connect failed"
|
||||
)))
|
||||
return
|
||||
}
|
||||
completionHandler(.allow)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
buffer.append(data)
|
||||
consume()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error {
|
||||
let ns = error as NSError
|
||||
lock.lock()
|
||||
let mismatch = lastPinMismatch
|
||||
lastPinMismatch = nil
|
||||
lock.unlock()
|
||||
if let mismatch {
|
||||
finishOnce(.failure(PortalClientError.tlsPinningMismatch(
|
||||
expected: mismatch.expected,
|
||||
got: mismatch.got
|
||||
)))
|
||||
return
|
||||
}
|
||||
if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled {
|
||||
finishOnce(.failure(CancellationError()))
|
||||
return
|
||||
}
|
||||
finishOnce(.failure(PortalClientError.communicationError(error.localizedDescription)))
|
||||
return
|
||||
}
|
||||
finishOnce(.success(()))
|
||||
}
|
||||
|
||||
private func consume() {
|
||||
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
|
||||
var lineData = buffer.subdata(in: buffer.startIndex..<newline)
|
||||
buffer.removeSubrange(buffer.startIndex...newline)
|
||||
if lineData.last == UInt8(ascii: "\r") {
|
||||
lineData.removeLast()
|
||||
}
|
||||
let line = String(data: lineData, encoding: .utf8) ?? ""
|
||||
handleSSELine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSSELine(_ line: String) {
|
||||
if line.hasPrefix(":") {
|
||||
return
|
||||
}
|
||||
if line.hasPrefix("data:") {
|
||||
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
|
||||
pendingDataLines.append(String(payload))
|
||||
return
|
||||
}
|
||||
if line.isEmpty {
|
||||
guard !pendingDataLines.isEmpty else { return }
|
||||
let json = pendingDataLines.joined(separator: "\n")
|
||||
pendingDataLines.removeAll(keepingCapacity: true)
|
||||
guard let raw = json.data(using: .utf8),
|
||||
let state = try? JSONDecoder().decode(PortalCameraState.self, from: raw) else {
|
||||
return
|
||||
}
|
||||
lock.lock()
|
||||
let emit = onState
|
||||
lock.unlock()
|
||||
emit?(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// PortalSrpClient.swift
|
||||
// PortalKit
|
||||
//
|
||||
// SRP-6a (RFC 5054 2048-bit) client with cryptographic TLS channel binding.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
public enum SrpError: LocalizedError, Sendable {
|
||||
case invalidParameter(String)
|
||||
case verificationFailed(String)
|
||||
case serverRejected(String)
|
||||
case tlsCertificateMissing
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidParameter(let m): return "SRP Parameter Error: \(m)"
|
||||
case .verificationFailed(let m): return "SRP Verification Failed: \(m)"
|
||||
case .serverRejected(let m): return "Portal Error: \(m)"
|
||||
case .tlsCertificateMissing: return "TLS server certificate could not be retrieved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class PortalSrpClient: @unchecked Sendable {
|
||||
public static let group = SrpGroup.rfc5054_2048
|
||||
public static let N = group.N
|
||||
public static let g = group.g
|
||||
public static let k = group.k
|
||||
|
||||
private let a: BigUInt
|
||||
public let A: BigUInt
|
||||
|
||||
public private(set) var K: Data?
|
||||
public private(set) var M1: Data?
|
||||
public private(set) var tlsCertHash: Data?
|
||||
|
||||
/// Initializes client with either a randomly generated ephemeral private key `a`
|
||||
/// or a predetermined `a` (primarily for test vectors and deterministic testing).
|
||||
public init(a: BigUInt? = nil) {
|
||||
if let customA = a {
|
||||
self.a = customA
|
||||
self.A = Self.g.power(customA, modulus: Self.N)
|
||||
} else {
|
||||
var aBytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &aBytes)
|
||||
let randVal = BigUInt(Data(aBytes))
|
||||
let privA = (randVal % (Self.N - 2)) + 1
|
||||
self.a = privA
|
||||
self.A = Self.g.power(privA, modulus: Self.N)
|
||||
}
|
||||
}
|
||||
|
||||
public var pubAHex: String {
|
||||
Self.bytesToHex(A.toPadded256Data())
|
||||
}
|
||||
|
||||
public var sessionKey: Data? {
|
||||
K
|
||||
}
|
||||
|
||||
public var clientM1: Data? {
|
||||
M1
|
||||
}
|
||||
|
||||
/// Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
|
||||
@discardableResult
|
||||
public func computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: Data) throws -> String {
|
||||
guard let salt = Self.hexToBytes(saltHex), !salt.isEmpty else {
|
||||
throw SrpError.invalidParameter("Invalid salt hex")
|
||||
}
|
||||
guard let bBytes = Self.hexToBytes(pubBHex), !bBytes.isEmpty else {
|
||||
throw SrpError.invalidParameter("Invalid B hex")
|
||||
}
|
||||
guard !tlsCertSha256.isEmpty else {
|
||||
throw SrpError.invalidParameter("TLS certificate hash cannot be empty")
|
||||
}
|
||||
|
||||
let B = BigUInt(bBytes)
|
||||
|
||||
// Safety check B % N != 0
|
||||
guard B % Self.N != 0 else {
|
||||
throw SrpError.invalidParameter("Server public value B % N == 0")
|
||||
}
|
||||
|
||||
// u = SHA256(pad256(A) || pad256(B))
|
||||
let uHash = Self.sha256(A.toPadded256Data(), B.toPadded256Data())
|
||||
let u = BigUInt(uHash)
|
||||
guard u != 0 else {
|
||||
throw SrpError.invalidParameter("Computed u == 0")
|
||||
}
|
||||
|
||||
// x = SHA256(salt || UTF8(pin))
|
||||
guard let pinData = pin.data(using: .utf8), !pinData.isEmpty else {
|
||||
throw SrpError.invalidParameter("Invalid or empty PIN")
|
||||
}
|
||||
let xHash = Self.sha256(salt, pinData)
|
||||
let x = BigUInt(xHash)
|
||||
|
||||
// S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N
|
||||
let S = SrpMath.computeClientS(
|
||||
B: B,
|
||||
k: Self.k,
|
||||
g: Self.g,
|
||||
x: x,
|
||||
a: a,
|
||||
u: u,
|
||||
N: Self.N
|
||||
)
|
||||
|
||||
// K = SHA256(pad256(S))
|
||||
let sessionK = Self.sha256(S.toPadded256Data())
|
||||
self.K = sessionK
|
||||
self.tlsCertHash = tlsCertSha256
|
||||
|
||||
// M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256)
|
||||
let clientM1 = Self.sha256(A.toPadded256Data(), B.toPadded256Data(), sessionK, salt, tlsCertSha256)
|
||||
self.M1 = clientM1
|
||||
return Self.bytesToHex(clientM1)
|
||||
}
|
||||
|
||||
/// Verify M2 returned by the server.
|
||||
public func verifyServerM2(serverM2Hex: String) throws {
|
||||
guard let expectedM1 = M1, let sessionK = K, let certHash = tlsCertHash else {
|
||||
throw SrpError.verificationFailed("Client state not initialized for verification")
|
||||
}
|
||||
guard let serverM2 = Self.hexToBytes(serverM2Hex) else {
|
||||
throw SrpError.verificationFailed("Invalid server M2 hex")
|
||||
}
|
||||
|
||||
// Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256)
|
||||
let expectedM2 = Self.sha256(A.toPadded256Data(), expectedM1, sessionK, certHash)
|
||||
|
||||
// Constant-time comparison
|
||||
guard SrpFormat.constantTimeEquals(expectedM2, serverM2) else {
|
||||
throw SrpError.verificationFailed("Server evidence M2 does not match (potential MITM or incorrect credentials)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
public static func sha256(_ parts: Data...) -> Data {
|
||||
SrpGroup.rfc5054_2048.hashAlgorithm.hash(parts)
|
||||
}
|
||||
|
||||
public static func bytesToHex(_ data: Data) -> String {
|
||||
SrpFormat.bytesToHex(data)
|
||||
}
|
||||
|
||||
public static func hexToBytes(_ hex: String) -> Data? {
|
||||
SrpFormat.hexToBytes(hex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// SrpGroup.swift
|
||||
// PortalKit
|
||||
//
|
||||
// SRP-6a RFC 5054 prime groups and cryptographic math helpers.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum SrpHashAlgorithm: Sendable {
|
||||
case sha1
|
||||
case sha256
|
||||
|
||||
public func hash(_ parts: Data...) -> Data {
|
||||
hash(parts)
|
||||
}
|
||||
|
||||
public func hash(_ parts: [Data]) -> Data {
|
||||
switch self {
|
||||
case .sha1:
|
||||
var hasher = Insecure.SHA1()
|
||||
for p in parts { hasher.update(data: p) }
|
||||
return Data(hasher.finalize())
|
||||
case .sha256:
|
||||
var hasher = SHA256()
|
||||
for p in parts { hasher.update(data: p) }
|
||||
return Data(hasher.finalize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SrpGroup: Sendable {
|
||||
public let N: BigUInt
|
||||
public let g: BigUInt
|
||||
public let byteLength: Int
|
||||
public let k: BigUInt
|
||||
public let hashAlgorithm: SrpHashAlgorithm
|
||||
|
||||
public init(NHex: String, g: BigUInt, byteLength: Int, hashAlgorithm: SrpHashAlgorithm) {
|
||||
let cleanHex = NHex.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression)
|
||||
guard let nVal = BigUInt(cleanHex, radix: 16) else {
|
||||
fatalError("Invalid N hex for SrpGroup")
|
||||
}
|
||||
self.N = nVal
|
||||
self.g = g
|
||||
self.byteLength = byteLength
|
||||
self.hashAlgorithm = hashAlgorithm
|
||||
|
||||
let nData = nVal.toPaddedData(byteCount: byteLength)
|
||||
let gData = g.toPaddedData(byteCount: byteLength)
|
||||
let kHash = hashAlgorithm.hash(nData, gData)
|
||||
self.k = BigUInt(kHash)
|
||||
}
|
||||
|
||||
/// RFC 5054 Appendix A 1024-bit group with SHA-1 (for test vector verification)
|
||||
public static let rfc5054_1024 = SrpGroup(
|
||||
NHex: """
|
||||
EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576\
|
||||
D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1\
|
||||
5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC\
|
||||
68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3
|
||||
""",
|
||||
g: BigUInt(2),
|
||||
byteLength: 128,
|
||||
hashAlgorithm: .sha1
|
||||
)
|
||||
|
||||
/// RFC 5054 2048-bit group with SHA-256 (standard Portal TV group)
|
||||
public static let rfc5054_2048 = SrpGroup(
|
||||
NHex: """
|
||||
FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74\
|
||||
020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437\
|
||||
4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED\
|
||||
EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05\
|
||||
98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB\
|
||||
9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B\
|
||||
E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\
|
||||
3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF
|
||||
""",
|
||||
g: BigUInt(2),
|
||||
byteLength: 256,
|
||||
hashAlgorithm: .sha256
|
||||
)
|
||||
}
|
||||
|
||||
public enum SrpMath {
|
||||
/// Calculate x according to RFC 5054: x = H(s | H(I | ":" | P))
|
||||
public static func computeRfc5054X(identity: String, password: String, salt: Data, hashAlgorithm: SrpHashAlgorithm) -> BigUInt {
|
||||
let colonData = ":".data(using: .utf8)!
|
||||
let identityData = identity.data(using: .utf8)!
|
||||
let passwordData = password.data(using: .utf8)!
|
||||
let innerHash = hashAlgorithm.hash(identityData, colonData, passwordData)
|
||||
let outerHash = hashAlgorithm.hash(salt, innerHash)
|
||||
return BigUInt(outerHash)
|
||||
}
|
||||
|
||||
/// Calculate x according to Portal TV protocol: x = SHA256(salt | UTF8(pin))
|
||||
public static func computePortalX(pin: String, salt: Data, hashAlgorithm: SrpHashAlgorithm = .sha256) -> BigUInt {
|
||||
let pinData = pin.data(using: .utf8)!
|
||||
let hash = hashAlgorithm.hash(salt, pinData)
|
||||
return BigUInt(hash)
|
||||
}
|
||||
|
||||
/// Verifier v = g^x mod N
|
||||
public static func computeVerifier(g: BigUInt, x: BigUInt, N: BigUInt) -> BigUInt {
|
||||
g.power(x, modulus: N)
|
||||
}
|
||||
|
||||
/// Public A = g^a mod N
|
||||
public static func computeA(g: BigUInt, a: BigUInt, N: BigUInt) -> BigUInt {
|
||||
g.power(a, modulus: N)
|
||||
}
|
||||
|
||||
/// Public B = (k*v + g^b) mod N
|
||||
public static func computeB(k: BigUInt, v: BigUInt, g: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt {
|
||||
let kv = (k * v) % N
|
||||
let gb = g.power(b, modulus: N)
|
||||
return (kv + gb) % N
|
||||
}
|
||||
|
||||
/// Scrambler u = H(PAD(A) | PAD(B))
|
||||
public static func computeU(A: BigUInt, B: BigUInt, padLength: Int, hashAlgorithm: SrpHashAlgorithm) -> BigUInt {
|
||||
let aData = A.toPaddedData(byteCount: padLength)
|
||||
let bData = B.toPaddedData(byteCount: padLength)
|
||||
let hash = hashAlgorithm.hash(aData, bData)
|
||||
return BigUInt(hash)
|
||||
}
|
||||
|
||||
/// Client premaster secret S = (B - k * (g^x mod N)) ^ (a + u * x) mod N
|
||||
public static func computeClientS(B: BigUInt, k: BigUInt, g: BigUInt, x: BigUInt, a: BigUInt, u: BigUInt, N: BigUInt) -> BigUInt {
|
||||
let gx = g.power(x, modulus: N)
|
||||
let kgx = (k * gx) % N
|
||||
let base = (B >= kgx) ? (B - kgx) : (N - ((kgx - B) % N))
|
||||
let exp = a + u * x
|
||||
return base.power(exp, modulus: N)
|
||||
}
|
||||
|
||||
/// Server premaster secret S = (A * (v^u mod N)) ^ b mod N
|
||||
public static func computeServerS(A: BigUInt, v: BigUInt, u: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt {
|
||||
let vu = v.power(u, modulus: N)
|
||||
let base = (A * vu) % N
|
||||
return base.power(b, modulus: N)
|
||||
}
|
||||
}
|
||||
|
||||
public enum SrpFormat {
|
||||
public static func bytesToHex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
public static func hexToBytes(_ hex: String) -> Data? {
|
||||
let clean = hex.components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
guard clean.count % 2 == 0 else { return nil }
|
||||
var data = Data(capacity: clean.count / 2)
|
||||
var index = clean.startIndex
|
||||
while index < clean.endIndex {
|
||||
let nextIndex = clean.index(index, offsetBy: 2)
|
||||
let byteStr = String(clean[index..<nextIndex])
|
||||
guard let byte = UInt8(byteStr, radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public static func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= (a[i] ^ b[i])
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
public func toPaddedData(byteCount: Int) -> Data {
|
||||
let raw = self.serialize()
|
||||
if raw.count >= byteCount {
|
||||
return raw.suffix(byteCount)
|
||||
}
|
||||
var padded = Data(repeating: 0, count: byteCount - raw.count)
|
||||
padded.append(raw)
|
||||
return padded
|
||||
}
|
||||
|
||||
public func toPadded256Data() -> Data {
|
||||
toPaddedData(byteCount: 256)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// SrpServerMock.swift
|
||||
// PortalKit
|
||||
//
|
||||
// Mock Portal TV SRP-6a server (matching PortalSrp.kt) for testing and simulations.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public final class SrpServerMock: @unchecked Sendable {
|
||||
public let pairingId: String
|
||||
public let pin: String
|
||||
public let salt: Data
|
||||
public let v: BigUInt
|
||||
public let b: BigUInt
|
||||
public let B: BigUInt
|
||||
public let group: SrpGroup
|
||||
|
||||
public var saltHex: String {
|
||||
SrpFormat.bytesToHex(salt)
|
||||
}
|
||||
|
||||
public var pubBHex: String {
|
||||
SrpFormat.bytesToHex(B.toPadded256Data())
|
||||
}
|
||||
|
||||
public init(
|
||||
pin: String = "123456",
|
||||
salt: Data? = nil,
|
||||
b: BigUInt? = nil,
|
||||
pairingId: String = UUID().uuidString,
|
||||
group: SrpGroup = .rfc5054_2048
|
||||
) {
|
||||
self.pairingId = pairingId
|
||||
self.pin = pin
|
||||
self.group = group
|
||||
|
||||
let effectiveSalt = salt ?? {
|
||||
var bytes = [UInt8](repeating: 0, count: 16)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 16, &bytes)
|
||||
return Data(bytes)
|
||||
}()
|
||||
self.salt = effectiveSalt
|
||||
|
||||
// x = SHA256(salt || UTF8(pin))
|
||||
let pinData = pin.data(using: .utf8)!
|
||||
let xHash = group.hashAlgorithm.hash(effectiveSalt, pinData)
|
||||
let x = BigUInt(xHash)
|
||||
|
||||
// v = g^x mod N
|
||||
self.v = group.g.power(x, modulus: group.N)
|
||||
|
||||
// b = private server exponent
|
||||
if let customB = b {
|
||||
self.b = customB
|
||||
} else {
|
||||
var bBytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &bBytes)
|
||||
let randVal = BigUInt(Data(bBytes))
|
||||
self.b = (randVal % (group.N - 2)) + 1
|
||||
}
|
||||
|
||||
// B = (k*v + g^b) mod N
|
||||
self.B = SrpMath.computeB(k: group.k, v: self.v, g: group.g, b: self.b, N: group.N)
|
||||
}
|
||||
|
||||
public struct VerifyResult: Sendable {
|
||||
public let success: Bool
|
||||
public let M2Hex: String?
|
||||
public let token: String?
|
||||
public let errorMessage: String?
|
||||
}
|
||||
|
||||
/// Verifies client credentials against the server's state and server certificate hash.
|
||||
public func verifyClient(pubAHex: String, clientM1Hex: String, serverTlsCertSha256: Data) -> VerifyResult {
|
||||
guard let aBytes = SrpFormat.hexToBytes(pubAHex), !aBytes.isEmpty else {
|
||||
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid A hex")
|
||||
}
|
||||
guard let clientM1 = SrpFormat.hexToBytes(clientM1Hex), !clientM1.isEmpty else {
|
||||
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid M1 hex")
|
||||
}
|
||||
|
||||
let A = BigUInt(aBytes)
|
||||
guard A % group.N != 0 else {
|
||||
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid public key A: A % N == 0")
|
||||
}
|
||||
|
||||
let padLen = group.byteLength
|
||||
let aPadded = A.toPaddedData(byteCount: padLen)
|
||||
let bPadded = B.toPaddedData(byteCount: padLen)
|
||||
|
||||
// u = H(PAD(A) || PAD(B))
|
||||
let uHash = group.hashAlgorithm.hash(aPadded, bPadded)
|
||||
let u = BigUInt(uHash)
|
||||
guard u != 0 else {
|
||||
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Computed u == 0")
|
||||
}
|
||||
|
||||
// Server computes S = (A * (v^u mod N)) ^ b mod N
|
||||
let S = SrpMath.computeServerS(A: A, v: v, u: u, b: b, N: group.N)
|
||||
let sPadded = S.toPaddedData(byteCount: padLen)
|
||||
|
||||
// K = H(PAD(S))
|
||||
let K = group.hashAlgorithm.hash(sPadded)
|
||||
|
||||
// Expected M1 = H(PAD(A) || PAD(B) || K || salt || serverTlsCertSha256)
|
||||
let expectedM1 = group.hashAlgorithm.hash(aPadded, bPadded, K, salt, serverTlsCertSha256)
|
||||
|
||||
guard SrpFormat.constantTimeEquals(clientM1, expectedM1) else {
|
||||
return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "M1 verification failed: wrong PIN or TLS MITM detected")
|
||||
}
|
||||
|
||||
// M2 = H(PAD(A) || M1 || K || serverTlsCertSha256)
|
||||
let M2 = group.hashAlgorithm.hash(aPadded, clientM1, K, serverTlsCertSha256)
|
||||
let m2Hex = SrpFormat.bytesToHex(M2)
|
||||
|
||||
// Generate token
|
||||
var tokenBytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &tokenBytes)
|
||||
let token = Data(tokenBytes).base64EncodedString()
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
return VerifyResult(success: true, M2Hex: m2Hex, token: token, errorMessage: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//
|
||||
// PortalTlsPinning.swift
|
||||
// PortalKit
|
||||
//
|
||||
// TLS Certificate Pinning, Challenge Evaluation, and Leaf Certificate Extraction.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
import CryptoKit
|
||||
import os.log
|
||||
|
||||
public enum PortalTlsPinning {
|
||||
private static let log = Logger(subsystem: "com.kovtash.portalkit", category: "tls")
|
||||
|
||||
public static func extractLeafCert(from serverTrust: SecTrust) -> SecCertificate? {
|
||||
if #available(macOS 12.0, *) {
|
||||
if let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], !chain.isEmpty {
|
||||
return chain[0]
|
||||
}
|
||||
}
|
||||
let count = SecTrustGetCertificateCount(serverTrust)
|
||||
guard count > 0 else { return nil }
|
||||
return SecTrustGetCertificateAtIndex(serverTrust, 0)
|
||||
}
|
||||
|
||||
public static func computeCertSha256(cert: SecCertificate) -> (data: Data, hex: String) {
|
||||
let certDer = SecCertificateCopyData(cert) as Data
|
||||
let digest = SHA256.hash(data: certDer)
|
||||
let data = Data(digest)
|
||||
let hex = SrpFormat.bytesToHex(data)
|
||||
return (data, hex)
|
||||
}
|
||||
|
||||
public static func evaluate(
|
||||
challenge: URLAuthenticationChallenge,
|
||||
pinnedFingerprint: String? = PortalAuth.pinnedCertSha256,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust else {
|
||||
// Avoid performDefaultHandling — it surfaces "invalid certificate" UI for self-signed servers.
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let cert = extractLeafCert(from: serverTrust) else {
|
||||
log.error("PortalKit TLS: No certificate found in server trust chain")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let (_, certHashHex) = computeCertSha256(cert: cert)
|
||||
|
||||
guard let pinned = pinnedFingerprint, !pinned.isEmpty else {
|
||||
log.error("PortalKit TLS: No pinned certificate configured; rejecting connection")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard certHashHex.caseInsensitiveCompare(pinned) == .orderedSame else {
|
||||
log.error("PortalKit TLS Pinning Mismatch! Expected: \(pinned, privacy: .public), Got: \(certHashHex, privacy: .public)")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Pin match is sufficient; SecTrustEvaluateWithError rejects our self-signed Portal cert.
|
||||
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
||||
}
|
||||
|
||||
/// Connects to a host and captures the presented TLS leaf certificate fingerprint.
|
||||
public static func fetchServerCertFingerprint(
|
||||
url: URL,
|
||||
timeout: TimeInterval = 10
|
||||
) async throws -> (data: Data, hex: String) {
|
||||
let delegate = SrpPairingSessionDelegate()
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = timeout
|
||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeout
|
||||
|
||||
// Perform request to trigger TLS handshake
|
||||
_ = try? await session.data(for: request)
|
||||
|
||||
guard let certData = delegate.capturedCertSha256,
|
||||
let certHex = delegate.capturedCertSha256Hex else {
|
||||
throw SrpError.tlsCertificateMissing
|
||||
}
|
||||
|
||||
return (certData, certHex)
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSessionDelegate for the pairing phase: accepts the self-signed certificate,
|
||||
/// but records its DER and SHA-256 digest so it can be channel-bound into SRP-6a.
|
||||
public final class SrpPairingSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _capturedCertDer: Data?
|
||||
private var _capturedCertSha256: Data?
|
||||
private var _capturedCertSha256Hex: String?
|
||||
|
||||
public override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
public var capturedCertDer: Data? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _capturedCertDer
|
||||
}
|
||||
|
||||
public var capturedCertSha256: Data? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _capturedCertSha256
|
||||
}
|
||||
|
||||
public var capturedCertSha256Hex: String? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return _capturedCertSha256Hex
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let serverTrust = challenge.protectionSpace.serverTrust,
|
||||
let cert = PortalTlsPinning.extractLeafCert(from: serverTrust) else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let (data, hex) = PortalTlsPinning.computeCertSha256(cert: cert)
|
||||
let der = SecCertificateCopyData(cert) as Data
|
||||
|
||||
lock.lock()
|
||||
self._capturedCertDer = der
|
||||
self._capturedCertSha256 = data
|
||||
self._capturedCertSha256Hex = hex
|
||||
lock.unlock()
|
||||
|
||||
// Accept trust for the pairing exchange. SRP-6a cryptographic channel binding
|
||||
// guarantees that if an attacker intercepted this TLS connection with a rogue cert,
|
||||
// the M1 verification on Portal will fail and pairing will be rejected.
|
||||
completionHandler(.useCredential, URLCredential(trust: serverTrust))
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSessionDelegate for standard requests (control, health, SSE) enforcing certificate pinning.
|
||||
public final class PortalPinnedSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
public let pinnedFingerprint: String?
|
||||
/// Set when the last challenge was rejected for a pin mismatch (for mapping NSURLErrorCancelled).
|
||||
public private(set) var lastPinMismatch: (expected: String, got: String)?
|
||||
private let lock = NSLock()
|
||||
|
||||
public init(pinnedFingerprint: String? = PortalAuth.pinnedCertSha256) {
|
||||
self.pinnedFingerprint = pinnedFingerprint
|
||||
super.init()
|
||||
}
|
||||
|
||||
public func consumePinMismatch() -> (expected: String, got: String)? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let value = lastPinMismatch
|
||||
lastPinMismatch = nil
|
||||
return value
|
||||
}
|
||||
|
||||
private func recordEvaluate(
|
||||
challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
PortalTlsPinning.evaluate(
|
||||
challenge: challenge,
|
||||
pinnedFingerprint: pinnedFingerprint
|
||||
) { [weak self] disposition, credential in
|
||||
if disposition == .cancelAuthenticationChallenge,
|
||||
let pinned = self?.pinnedFingerprint,
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust,
|
||||
let cert = PortalTlsPinning.extractLeafCert(from: trust) {
|
||||
let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert)
|
||||
if pinned.caseInsensitiveCompare(got) != .orderedSame {
|
||||
self?.lock.lock()
|
||||
self?.lastPinMismatch = (expected: pinned, got: got)
|
||||
self?.lock.unlock()
|
||||
}
|
||||
}
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
}
|
||||
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
recordEvaluate(challenge: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
/// Async `bytes(for:)` / `data(for:)` deliver server-trust challenges here on recent macOS.
|
||||
public func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
recordEvaluate(challenge: challenge, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
public struct PortalKitVersion {
|
||||
public static let version = "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//
|
||||
// main.swift
|
||||
// portalkit-cli
|
||||
//
|
||||
// Command-line interface for Portal TV pairing, TLS pinning, control, and MITM defense verification.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import PortalKit
|
||||
|
||||
/// Hybrid credential storage that reads/writes both macOS Keychain and ~/.portalkit/credentials.json
|
||||
final class CliCredentialStorage: CredentialStorage, @unchecked Sendable {
|
||||
private let keychain = KeychainCredentialStorage(accessGroup: nil)
|
||||
private let fileStorage = FileCredentialStorage()
|
||||
|
||||
func getAuthToken() -> String? {
|
||||
if let token = keychain.getAuthToken(), !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
return fileStorage.getAuthToken()
|
||||
}
|
||||
|
||||
func getPinnedCertSha256() -> String? {
|
||||
if let cert = keychain.getPinnedCertSha256(), !cert.isEmpty {
|
||||
return cert
|
||||
}
|
||||
return fileStorage.getPinnedCertSha256()
|
||||
}
|
||||
|
||||
func save(token: String, certSha256: String) {
|
||||
keychain.save(token: token, certSha256: certSha256)
|
||||
fileStorage.save(token: token, certSha256: certSha256)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
keychain.clear()
|
||||
fileStorage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct PortalKitCli {
|
||||
static let storage = CliCredentialStorage()
|
||||
|
||||
static func printUsage() {
|
||||
let usage = """
|
||||
PortalKit CLI - Portal TV pairing, TLS pinning, and control tool
|
||||
|
||||
USAGE:
|
||||
portalkit-cli <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
pair <host> [--pin <pin>]
|
||||
Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN,
|
||||
verifies server M2 proof, and pins the TLS certificate.
|
||||
|
||||
status <host>
|
||||
Checks Portal TV service status, inspects the presented TLS leaf certificate,
|
||||
and verifies certificate pinning state.
|
||||
|
||||
control <host> <command>
|
||||
Sends control commands over pinned HTTPS. Mutations return {"ok":true};
|
||||
live state is on GET /control/events (SSE).
|
||||
({"mode":"…","config":{…}}); failures return {"error":"…","message":"…"}.
|
||||
|
||||
Commands:
|
||||
state
|
||||
mode <DefaultAuto|Desk|Meeting|Fixed>
|
||||
fixed?x=<0-1>&y=<0-1>&scale=<0.1-1>
|
||||
desk?tightness=<0-1>
|
||||
|
||||
test-mitm <host>
|
||||
Simulates a Man-in-the-Middle (MITM) attack with a substituted TLS certificate
|
||||
to verify that SRP-6a channel binding prevents unauthorized interception.
|
||||
|
||||
OPTIONS:
|
||||
--pin, -p <pin> 6-digit PIN displayed on Portal TV (for 'pair' command)
|
||||
--help, -h Show this help reference
|
||||
|
||||
EXAMPLES:
|
||||
portalkit-cli pair 10.0.0.10:5654 --pin 123456
|
||||
portalkit-cli status 10.0.0.10:5654
|
||||
portalkit-cli control 10.0.0.10:5654 state
|
||||
portalkit-cli control 10.0.0.10:5654 mode Desk
|
||||
portalkit-cli control 10.0.0.10:5654 fixed?x=0.5&y=0.5&scale=1.0
|
||||
portalkit-cli control 10.0.0.10:5654 desk?tightness=0.5
|
||||
portalkit-cli test-mitm 10.0.0.10:5654
|
||||
"""
|
||||
print(usage)
|
||||
}
|
||||
|
||||
static func main() async {
|
||||
var args = Array(CommandLine.arguments.dropFirst())
|
||||
|
||||
if args.isEmpty || args.contains("--help") || args.contains("-h") || args.first == "help" {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
let command = args.removeFirst()
|
||||
|
||||
switch command {
|
||||
case "pair":
|
||||
await handlePair(args: args)
|
||||
case "status":
|
||||
await handleStatus(args: args)
|
||||
case "control":
|
||||
await handleControl(args: args)
|
||||
case "test-mitm":
|
||||
await handleTestMitm(args: args)
|
||||
default:
|
||||
print("Unknown command: '\(command)'. Run with --help for usage.")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
static func handlePair(args: [String]) async {
|
||||
guard let host = args.first, !host.hasPrefix("-") else {
|
||||
print("Error: Missing <host> parameter.\nUsage: portalkit-cli pair <host> [--pin <pin>]")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
var pin: String? = nil
|
||||
var idx = 1
|
||||
while idx < args.count {
|
||||
if (args[idx] == "--pin" || args[idx] == "-p"), idx + 1 < args.count {
|
||||
pin = args[idx + 1]
|
||||
idx += 2
|
||||
} else {
|
||||
idx += 1
|
||||
}
|
||||
}
|
||||
|
||||
let client = PortalClient(host: host, credentialStorage: storage)
|
||||
|
||||
do {
|
||||
print("Initiating SRP-6a pairing with Portal TV at \(client.host)…")
|
||||
let session = try await client.initiatePairing()
|
||||
print("Connected over ephemeral TLS.")
|
||||
print("Captured Server Leaf Cert SHA-256: \(session.capturedCertSha256Hex)")
|
||||
print("Pairing ID: \(session.pairingId)")
|
||||
if let exp = session.expiresIn {
|
||||
print("Session expires in: \(exp)s")
|
||||
}
|
||||
|
||||
let effectivePin: String
|
||||
if let provided = pin, !provided.isEmpty {
|
||||
effectivePin = provided
|
||||
} else {
|
||||
print("Enter the 6-digit PIN displayed on Portal TV: ", terminator: "")
|
||||
fflush(stdout)
|
||||
effectivePin = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
guard !effectivePin.isEmpty else {
|
||||
print("Error: No PIN entered.")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
print("Computing channel-bound M1 proof…")
|
||||
print("Submitting M1 and verifying Portal TV M2 proof…")
|
||||
let result = try await client.completePairing(session: session, pin: effectivePin)
|
||||
|
||||
print("\nPairing successful!")
|
||||
print("Server Proof M2 Verified: \(result.serverM2Hex.prefix(16))…")
|
||||
print("Bearer Token: \(result.token.prefix(12))…")
|
||||
print("TLS Certificate Pinned: \(result.pinnedCertSha256Hex)")
|
||||
print("Credentials saved successfully.")
|
||||
} catch {
|
||||
print("\nPairing failed: \(error.localizedDescription)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func handleStatus(args: [String]) async {
|
||||
guard let host = args.first, !host.hasPrefix("-") else {
|
||||
print("Error: Missing <host> parameter.\nUsage: portalkit-cli status <host>")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let client = PortalClient(host: host, credentialStorage: storage)
|
||||
print("Checking Portal TV status at \(client.host)…")
|
||||
|
||||
do {
|
||||
let status = try await client.status()
|
||||
print("Host: \(status.host)")
|
||||
print("Service Online: \(status.isOnline ? "YES" : "NO")")
|
||||
print("Details: \(status.details)")
|
||||
if let srvSha = status.serverReportedCertSha256 {
|
||||
print("Server Cert Digest: \(srvSha)")
|
||||
}
|
||||
if let leafSha = status.leafCertSha256 {
|
||||
print("Leaf Cert SHA-256: \(leafSha)")
|
||||
} else {
|
||||
print("Leaf Cert SHA-256: [Not reached / no TLS cert]")
|
||||
}
|
||||
|
||||
if let pinned = status.pinnedCertSha256 {
|
||||
print("Pinned Cert Digest: \(pinned)")
|
||||
print("Pin Status: \(status.isCertPinMatching ? "VALID (Match)" : "MISMATCH / INVALID")")
|
||||
} else {
|
||||
print("Pinned Cert Digest: [None configured]")
|
||||
}
|
||||
print("Paired: \(status.isPaired ? "YES" : "NO")")
|
||||
} catch {
|
||||
print("Status check failed: \(error.localizedDescription)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func handleControl(args: [String]) async {
|
||||
guard let host = args.first, !host.hasPrefix("-") else {
|
||||
print("Error: Missing <host> parameter.\nUsage: portalkit-cli control <host> <command>")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let commandArgs = Array(args.dropFirst())
|
||||
guard !commandArgs.isEmpty else {
|
||||
print("Error: Missing <command> parameter.\nUsage: portalkit-cli control <host> <command>")
|
||||
print("Examples:\n control <host> mode Desk\n control <host> fixed?x=0.5&y=0.5&scale=1.0")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let fullCommand = commandArgs.joined(separator: " ")
|
||||
let client = PortalClient(host: host, credentialStorage: storage)
|
||||
|
||||
print("Sending control command '\(fullCommand)' to \(client.host) via pinned HTTPS…")
|
||||
do {
|
||||
let response = try await client.control(command: fullCommand)
|
||||
print("Response: \(response)")
|
||||
} catch {
|
||||
print("Control command failed: \(error.localizedDescription)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func handleTestMitm(args: [String]) async {
|
||||
guard let host = args.first, !host.hasPrefix("-") else {
|
||||
print("Error: Missing <host> parameter.\nUsage: portalkit-cli test-mitm <host>")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let client = PortalClient(host: host, credentialStorage: storage)
|
||||
print("Testing SRP-6a TLS Channel Binding Defense against Portal TV at \(client.host)…")
|
||||
|
||||
do {
|
||||
let result = try await client.testMitm()
|
||||
print("\n================== CHANNEL BINDING DEFENSE TEST ==================")
|
||||
print("Attack Simulation: \(result.attackDescription)")
|
||||
print("Genuine Server Cert SHA-256: \(result.realCertSha256Hex)")
|
||||
print("Simulated MITM Cert SHA-256: \(result.simulatedMitmCertSha256Hex)")
|
||||
print("Server Response: \(result.serverRejectionMessage)")
|
||||
if let remaining = result.attemptsRemaining {
|
||||
print("Pairing Attempts Left: \(remaining)")
|
||||
}
|
||||
print("------------------------------------------------------------------")
|
||||
if result.defenseSuccessful {
|
||||
print("Verdict: PASSED - Server successfully rejected the altered certificate binding!")
|
||||
print("Security Guarantee: An active MITM proxy cannot forge authentication without detection.")
|
||||
} else {
|
||||
print("Verdict: FAILED - Server unexpectedly accepted the connection!")
|
||||
exit(1)
|
||||
}
|
||||
print("==================================================================")
|
||||
} catch {
|
||||
print("Test failed to execute: \(error.localizedDescription)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// BigUIntPaddingTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of BigUInt 256-byte padding, truncation, and hex conversion edge cases.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class BigUIntPaddingTests: XCTestCase {
|
||||
func testZeroPaddingTo256Bytes() {
|
||||
let zero = BigUInt(0)
|
||||
let padded = zero.toPadded256Data()
|
||||
XCTAssertEqual(padded.count, 256, "Zero must produce exactly 256 bytes")
|
||||
XCTAssertEqual(padded, Data(repeating: 0, count: 256))
|
||||
}
|
||||
|
||||
func testSmallNumberPadding() {
|
||||
let one = BigUInt(1)
|
||||
let padded = one.toPadded256Data()
|
||||
XCTAssertEqual(padded.count, 256)
|
||||
XCTAssertEqual(padded.prefix(255), Data(repeating: 0, count: 255))
|
||||
XCTAssertEqual(padded.last, 1)
|
||||
|
||||
let value = BigUInt(0x12345678)
|
||||
let paddedValue = value.toPadded256Data()
|
||||
XCTAssertEqual(paddedValue.count, 256)
|
||||
XCTAssertEqual(paddedValue.suffix(4), Data([0x12, 0x34, 0x56, 0x78]))
|
||||
}
|
||||
|
||||
func testExact256ByteValue() {
|
||||
// Portal TV 2048-bit prime N is exactly 256 bytes
|
||||
let N = PortalSrpClient.N
|
||||
let paddedN = N.toPadded256Data()
|
||||
XCTAssertEqual(paddedN.count, 256)
|
||||
XCTAssertEqual(paddedN.first, 0xFF)
|
||||
XCTAssertEqual(paddedN.last, 0xFF)
|
||||
|
||||
// Raw serialize count for 2048-bit N starting with 0xFF is 256 bytes
|
||||
XCTAssertEqual(N.serialize().count, 256)
|
||||
XCTAssertEqual(paddedN, N.serialize())
|
||||
}
|
||||
|
||||
func testVariableLengthPadding() {
|
||||
let val = BigUInt(0xAABB)
|
||||
// Pad to 4 bytes: 00 00 AA BB
|
||||
let p4 = val.toPaddedData(byteCount: 4)
|
||||
XCTAssertEqual(p4, Data([0x00, 0x00, 0xAA, 0xBB]))
|
||||
|
||||
// Pad to 2 bytes: AA BB
|
||||
let p2 = val.toPaddedData(byteCount: 2)
|
||||
XCTAssertEqual(p2, Data([0xAA, 0xBB]))
|
||||
|
||||
// Truncate to 1 byte: BB (suffix)
|
||||
let p1 = val.toPaddedData(byteCount: 1)
|
||||
XCTAssertEqual(p1, Data([0xBB]))
|
||||
}
|
||||
|
||||
func testHexConversionEdgeCases() {
|
||||
// Empty data
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data()), "")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes(""), Data())
|
||||
|
||||
// Leading zero single byte
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data([0x05])), "05")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("05"), Data([0x05]))
|
||||
|
||||
// High nybble / low nybble
|
||||
XCTAssertEqual(SrpFormat.bytesToHex(Data([0xF0, 0x0F])), "f00f")
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("f00f"), Data([0xF0, 0x0F]))
|
||||
XCTAssertEqual(SrpFormat.hexToBytes("F00F"), Data([0xF0, 0x0F])) // case insensitive
|
||||
|
||||
// Whitespace and newline tolerance
|
||||
let spacedHex = " f0 0f \n 12\t 34 \r\n"
|
||||
XCTAssertEqual(SrpFormat.hexToBytes(spacedHex), Data([0xF0, 0x0F, 0x12, 0x34]))
|
||||
|
||||
// Invalid: odd length
|
||||
XCTAssertNil(SrpFormat.hexToBytes("123"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("f"))
|
||||
|
||||
// Invalid: non-hex characters
|
||||
XCTAssertNil(SrpFormat.hexToBytes("123g"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("zz"))
|
||||
XCTAssertNil(SrpFormat.hexToBytes("!!"))
|
||||
}
|
||||
|
||||
func testConstantTimeEqualsEdgeCases() {
|
||||
let a = Data([0x01, 0x02, 0x03, 0x04])
|
||||
let b = Data([0x01, 0x02, 0x03, 0x04])
|
||||
let diffStart = Data([0xFF, 0x02, 0x03, 0x04])
|
||||
let diffMid = Data([0x01, 0xFF, 0x03, 0x04])
|
||||
let diffEnd = Data([0x01, 0x02, 0x03, 0xFF])
|
||||
let diffLength = Data([0x01, 0x02, 0x03])
|
||||
|
||||
XCTAssertTrue(SrpFormat.constantTimeEquals(a, b))
|
||||
XCTAssertTrue(SrpFormat.constantTimeEquals(Data(), Data()))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffStart))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffMid))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffEnd))
|
||||
XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffLength))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// ChannelBindingTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of cryptographic TLS channel binding in SRP-6a M1 and M2.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class ChannelBindingTests: XCTestCase {
|
||||
let pin = "481516"
|
||||
let genuineCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("GENUINE_PORTAL_CERT_DER".data(using: .utf8)!)
|
||||
let rogueCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("ROGUE_PROXY_CERT_DER".data(using: .utf8)!)
|
||||
|
||||
func testMatchingCertHashSucceeds() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Both use the same genuine TLS cert hash
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(serverRes.success)
|
||||
XCTAssertNotNil(serverRes.M2Hex)
|
||||
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: serverRes.M2Hex!))
|
||||
}
|
||||
|
||||
func testAlteredCertHashFailsServerM1Verification() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client is tricked into connecting through a MITM proxy presenting rogue cert
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: rogueCertHash // Client uses rogue proxy cert hash
|
||||
)
|
||||
|
||||
// Portal TV verifies against its real local certificate hash
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash // Server uses genuine cert hash
|
||||
)
|
||||
|
||||
XCTAssertFalse(serverRes.success, "Server must reject client M1 when TLS cert hashes differ")
|
||||
XCTAssertNil(serverRes.token)
|
||||
XCTAssertNil(serverRes.M2Hex)
|
||||
}
|
||||
|
||||
func testAlteredCertHashFailsClientM2Verification() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 bound to genuine cert
|
||||
_ = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
// Attacker attempts to forge M2 or server computes M2 bound to a different cert hash
|
||||
let aPadded = client.A.toPadded256Data()
|
||||
let fakeM2 = SrpGroup.rfc5054_2048.hashAlgorithm.hash(
|
||||
aPadded,
|
||||
client.clientM1!,
|
||||
client.sessionKey!,
|
||||
rogueCertHash // Mismatched cert hash in M2
|
||||
)
|
||||
let fakeM2Hex = SrpFormat.bytesToHex(fakeM2)
|
||||
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: fakeM2Hex)) { error in
|
||||
guard case SrpError.verificationFailed(let msg) = error else {
|
||||
XCTFail("Unexpected error type: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(msg.contains("Server evidence M2 does not match"))
|
||||
}
|
||||
}
|
||||
|
||||
func testSingleBitFlipInCertHashFails() throws {
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Flip 1 bit in the genuine cert hash
|
||||
var tamperedCertHash = genuineCertHash
|
||||
tamperedCertHash[0] ^= 0x01
|
||||
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: tamperedCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: genuineCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(serverRes.success, "Single-bit flip in TLS cert hash must fail verification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// ClientAndAuthTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of CredentialStorage abstractions, PortalAuth, and PortalClient models.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class ClientAndAuthTests: XCTestCase {
|
||||
func testInMemoryCredentialStorage() {
|
||||
let storage = InMemoryCredentialStorage()
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
XCTAssertNil(storage.getPinnedCertSha256())
|
||||
|
||||
storage.save(token: "test_token_123", certSha256: "AABBCCDDEEFF")
|
||||
XCTAssertEqual(storage.getAuthToken(), "test_token_123")
|
||||
XCTAssertEqual(storage.getPinnedCertSha256(), "aabbccddeeff") // lowercased
|
||||
|
||||
storage.clear()
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
XCTAssertNil(storage.getPinnedCertSha256())
|
||||
}
|
||||
|
||||
func testFileCredentialStorage() {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let tempFile = tempDir.appendingPathComponent("test_credentials_\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: tempFile) }
|
||||
|
||||
let storage = FileCredentialStorage(fileURL: tempFile)
|
||||
XCTAssertNil(storage.getAuthToken())
|
||||
|
||||
storage.save(token: "tok_abc", certSha256: "010203040506")
|
||||
XCTAssertEqual(storage.getAuthToken(), "tok_abc")
|
||||
XCTAssertEqual(storage.getPinnedCertSha256(), "010203040506")
|
||||
|
||||
// Create fresh instance pointing to same file
|
||||
let reloadStorage = FileCredentialStorage(fileURL: tempFile)
|
||||
XCTAssertEqual(reloadStorage.getAuthToken(), "tok_abc")
|
||||
XCTAssertEqual(reloadStorage.getPinnedCertSha256(), "010203040506")
|
||||
|
||||
reloadStorage.clear()
|
||||
XCTAssertNil(reloadStorage.getAuthToken())
|
||||
}
|
||||
|
||||
func testPortalAuthFacade() {
|
||||
let original = PortalAuth.defaultStorage
|
||||
defer { PortalAuth.defaultStorage = original }
|
||||
|
||||
let mockStorage = InMemoryCredentialStorage()
|
||||
PortalAuth.defaultStorage = mockStorage
|
||||
|
||||
PortalAuth.save(token: "facade_token", certSha256: "CAFEBABE")
|
||||
XCTAssertEqual(PortalAuth.token, "facade_token")
|
||||
XCTAssertEqual(PortalAuth.pinnedCertSha256, "cafebabe")
|
||||
|
||||
PortalAuth.clear()
|
||||
XCTAssertNil(PortalAuth.token)
|
||||
XCTAssertNil(PortalAuth.pinnedCertSha256)
|
||||
}
|
||||
|
||||
func testPortalClientHostNormalization() {
|
||||
let c1 = PortalClient(host: "10.0.0.10")
|
||||
XCTAssertEqual(c1.host, "10.0.0.10:\(PortalEndpoints.port)")
|
||||
|
||||
let c2 = PortalClient(host: "https://10.0.0.10:9000/")
|
||||
XCTAssertEqual(c2.host, "10.0.0.10:9000")
|
||||
|
||||
let c3 = PortalClient(host: "http://myportal.local:\(PortalEndpoints.port)")
|
||||
XCTAssertEqual(c3.host, "myportal.local:\(PortalEndpoints.port)")
|
||||
}
|
||||
|
||||
func testPortalClientControlRequiresPairing() async {
|
||||
let storage = InMemoryCredentialStorage()
|
||||
let client = PortalClient(host: "127.0.0.1", credentialStorage: storage)
|
||||
|
||||
do {
|
||||
_ = try await client.control(command: "mode Desk")
|
||||
XCTFail("Should throw notPaired error")
|
||||
} catch {
|
||||
guard case PortalClientError.notPaired = error else {
|
||||
XCTFail("Expected notPaired error, got \(error)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testTlsPinningChallengeEvaluationNonServerTrust() {
|
||||
let space = URLProtectionSpace(
|
||||
host: "localhost",
|
||||
port: PortalEndpoints.port,
|
||||
protocol: "https",
|
||||
realm: nil,
|
||||
authenticationMethod: NSURLAuthenticationMethodHTTPBasic
|
||||
)
|
||||
let challenge = URLAuthenticationChallenge(protectionSpace: space, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: DummyChallengeSender())
|
||||
|
||||
let exp = expectation(description: "Challenge evaluated")
|
||||
PortalTlsPinning.evaluate(challenge: challenge, pinnedFingerprint: "somehash") { disposition, credential in
|
||||
XCTAssertEqual(disposition, .cancelAuthenticationChallenge)
|
||||
XCTAssertNil(credential)
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private final class DummyChallengeSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
func performDefaultHandling(for challenge: URLAuthenticationChallenge) {}
|
||||
func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// Rfc5054VectorsTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of RFC 5054 Appendix B test vectors (1024-bit group with SHA-1).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class Rfc5054VectorsTests: XCTestCase {
|
||||
let group = SrpGroup.rfc5054_1024
|
||||
|
||||
// RFC 5054 Appendix B inputs
|
||||
let I = "alice"
|
||||
let P = "password123"
|
||||
let sHex = "BEB25379D1A8581EB5A727673A2441EE"
|
||||
|
||||
// RFC 5054 Appendix B expected values
|
||||
let expectedKHex = "7556AA045AEF2CDD07ABAF0F665C3E818913186F"
|
||||
let expectedXHex = "94B7555AABE9127CC58CCF4993DB6CF84D16C124"
|
||||
let expectedVHex = """
|
||||
7E273DE8696FFC4F4E337D05B4B375BEB0DDE1569E8FA00A9886D8129BADA1F1\
|
||||
822223CA1A605B530E379BA4729FDC59F105B4787E5186F5C671085A1447B52A\
|
||||
48CF1970B4FB6F8400BBF4CEBFBB168152E08AB5EA53D15C1AFF87B2B9DA6E04\
|
||||
E058AD51CC72BFC9033B564E26480D78E955A5E29E7AB245DB2BE315E2099AFB
|
||||
"""
|
||||
let expectedAHex = "60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393"
|
||||
let expectedBHex = "E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20"
|
||||
let expectedPubAHex = """
|
||||
61D5E490F6F1B79547B0704C436F523DD0E560F0C64115BB72557EC44352E890\
|
||||
3211C04692272D8B2D1A5358A2CF1B6E0BFCF99F921530EC8E39356179EAE45E\
|
||||
42BA92AEACED825171E1E8B9AF6D9C03E1327F44BE087EF06530E69F66615261\
|
||||
EEF54073CA11CF5858F0EDFDFE15EFEAB349EF5D76988A3672FAC47B0769447B
|
||||
"""
|
||||
let expectedPubBHex = """
|
||||
BD0C61512C692C0CB6D041FA01BB152D4916A1E77AF46AE105393011BAF38964\
|
||||
DC46A0670DD125B95A981652236F99D9B681CBF87837EC996C6DA04453728610\
|
||||
D0C6DDB58B318885D7D82C7F8DEB75CE7BD4FBAA37089E6F9C6059F388838E7A\
|
||||
00030B331EB76840910440B1B27AAEAEEB4012B7D7665238A8E3FB004B117B58
|
||||
"""
|
||||
let expectedUHex = "CE38B9593487DA98554ED47D70A7AE5F462EF019"
|
||||
let expectedSHex = """
|
||||
B0DC82BABCF30674AE450C0287745E7990A3381F63B387AAF271A10D233861E3\
|
||||
59B48220F7C4693C9AE12B0A6F67809F0876E2D013800D6C41BB59B6D5979B5C\
|
||||
00A172B4A2A5903A0BDCAF8A709585EB2AFAFA8F3499B200210DCC1F10EB3394\
|
||||
3CD67FC88A2F39A4BE5BEC4EC0A3212DC346D7E474B29EDE8A469FFECA686E5A
|
||||
"""
|
||||
|
||||
func testMultiplierK() {
|
||||
let computedK = group.k
|
||||
let computedKHex = SrpFormat.bytesToHex(computedK.serialize())
|
||||
XCTAssertEqual(computedKHex.lowercased(), expectedKHex.lowercased(), "RFC 5054 multiplier k mismatch")
|
||||
}
|
||||
|
||||
func testPrivateKeyX() {
|
||||
guard let sData = SrpFormat.hexToBytes(sHex) else {
|
||||
XCTFail("Failed to decode salt hex")
|
||||
return
|
||||
}
|
||||
let x = SrpMath.computeRfc5054X(identity: I, password: P, salt: sData, hashAlgorithm: group.hashAlgorithm)
|
||||
let xHex = SrpFormat.bytesToHex(x.serialize())
|
||||
XCTAssertEqual(xHex.lowercased(), expectedXHex.lowercased(), "RFC 5054 private key x mismatch")
|
||||
}
|
||||
|
||||
func testVerifierV() {
|
||||
guard let xVal = BigUInt(expectedXHex, radix: 16) else {
|
||||
XCTFail("Failed to parse expected x")
|
||||
return
|
||||
}
|
||||
let v = SrpMath.computeVerifier(g: group.g, x: xVal, N: group.N)
|
||||
let vHex = SrpFormat.bytesToHex(v.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(vHex.lowercased(), expectedVHex.lowercased(), "RFC 5054 verifier v mismatch")
|
||||
}
|
||||
|
||||
func testPublicA() {
|
||||
guard let aVal = BigUInt(expectedAHex, radix: 16) else {
|
||||
XCTFail("Failed to parse expected a")
|
||||
return
|
||||
}
|
||||
let A = SrpMath.computeA(g: group.g, a: aVal, N: group.N)
|
||||
let aHex = SrpFormat.bytesToHex(A.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(aHex.lowercased(), expectedPubAHex.lowercased(), "RFC 5054 public key A mismatch")
|
||||
}
|
||||
|
||||
func testPublicB() {
|
||||
guard let bVal = BigUInt(expectedBHex, radix: 16),
|
||||
let vVal = BigUInt(expectedVHex, radix: 16) else {
|
||||
XCTFail("Failed to parse b or v")
|
||||
return
|
||||
}
|
||||
let B = SrpMath.computeB(k: group.k, v: vVal, g: group.g, b: bVal, N: group.N)
|
||||
let bHex = SrpFormat.bytesToHex(B.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(bHex.lowercased(), expectedPubBHex.lowercased(), "RFC 5054 public key B mismatch")
|
||||
}
|
||||
|
||||
func testScramblerU() {
|
||||
guard let aVal = BigUInt(expectedPubAHex, radix: 16),
|
||||
let bVal = BigUInt(expectedPubBHex, radix: 16) else {
|
||||
XCTFail("Failed to parse A or B")
|
||||
return
|
||||
}
|
||||
let u = SrpMath.computeU(A: aVal, B: bVal, padLength: 128, hashAlgorithm: group.hashAlgorithm)
|
||||
let uHex = SrpFormat.bytesToHex(u.serialize())
|
||||
XCTAssertEqual(uHex.lowercased(), expectedUHex.lowercased(), "RFC 5054 scrambler u mismatch")
|
||||
}
|
||||
|
||||
func testPremasterSecretS() {
|
||||
guard let aVal = BigUInt(expectedAHex, radix: 16),
|
||||
let bVal = BigUInt(expectedBHex, radix: 16),
|
||||
let pubA = BigUInt(expectedPubAHex, radix: 16),
|
||||
let pubB = BigUInt(expectedPubBHex, radix: 16),
|
||||
let xVal = BigUInt(expectedXHex, radix: 16),
|
||||
let vVal = BigUInt(expectedVHex, radix: 16),
|
||||
let uVal = BigUInt(expectedUHex, radix: 16) else {
|
||||
XCTFail("Failed to parse test parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Client computation: S = (B - k * (g^x mod N)) ^ (a + u * x) mod N
|
||||
let clientS = SrpMath.computeClientS(
|
||||
B: pubB,
|
||||
k: group.k,
|
||||
g: group.g,
|
||||
x: xVal,
|
||||
a: aVal,
|
||||
u: uVal,
|
||||
N: group.N
|
||||
)
|
||||
let clientSHex = SrpFormat.bytesToHex(clientS.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(clientSHex.lowercased(), expectedSHex.lowercased(), "Client premaster secret S mismatch")
|
||||
|
||||
// Server computation: S = (A * (v^u mod N)) ^ b mod N
|
||||
let serverS = SrpMath.computeServerS(
|
||||
A: pubA,
|
||||
v: vVal,
|
||||
u: uVal,
|
||||
b: bVal,
|
||||
N: group.N
|
||||
)
|
||||
let serverSHex = SrpFormat.bytesToHex(serverS.toPaddedData(byteCount: 128))
|
||||
XCTAssertEqual(serverSHex.lowercased(), expectedSHex.lowercased(), "Server premaster secret S mismatch")
|
||||
|
||||
// Mutual agreement
|
||||
XCTAssertEqual(clientS, serverS, "Client and Server premaster secrets do not match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// SafetyChecksTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of SRP parameter validation and safety guards (RFC 5054).
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class SafetyChecksTests: XCTestCase {
|
||||
let validCertHash = Data(repeating: 0xaa, count: 32)
|
||||
let validSaltHex = "0102030405060708090a0b0c0d0e0f10"
|
||||
let validPubBHex = SrpFormat.bytesToHex(BigUInt(123456789).toPadded256Data())
|
||||
let validPin = "123456"
|
||||
|
||||
func testInvalidSaltThrows() {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Empty salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("salt"))
|
||||
}
|
||||
|
||||
// Odd length hex salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "abc", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
|
||||
// Non-hex characters in salt
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: "invalid-hex-characters!!", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
}
|
||||
|
||||
func testInvalidBModuloNThrows() {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// B = 0 -> B % N == 0
|
||||
let zeroBHex = SrpFormat.bytesToHex(BigUInt(0).toPadded256Data())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: zeroBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// B = N -> B % N == 0
|
||||
let nBHex = SrpFormat.bytesToHex(PortalSrpClient.N.toPadded256Data())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: nBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// B = 2*N -> B % N == 0
|
||||
let twoNBHex = SrpFormat.bytesToHex((PortalSrpClient.N * 2).serialize())
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: twoNBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("B % N == 0"))
|
||||
}
|
||||
|
||||
// Empty pubBHex
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: "", pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
|
||||
// Non-hex pubBHex
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: "not_hex", pin: validPin, tlsCertSha256: validCertHash)
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyPinThrows() {
|
||||
let client = PortalSrpClient()
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: "", tlsCertSha256: validCertHash)
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("PIN"))
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyTlsCertHashThrows() {
|
||||
let client = PortalSrpClient()
|
||||
XCTAssertThrowsError(
|
||||
try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: Data())
|
||||
) { error in
|
||||
guard case SrpError.invalidParameter(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("certificate"))
|
||||
}
|
||||
}
|
||||
|
||||
func testInvalidUZeroCheck() {
|
||||
let _ = SrpServerMock(pin: validPin)
|
||||
// If a server or client encounters u == 0, it must be rejected as an invalid scrambler.
|
||||
let uZero = BigUInt(0)
|
||||
XCTAssertEqual(uZero, 0)
|
||||
|
||||
// Verify SrpError formatting for u == 0
|
||||
let err = SrpError.invalidParameter("Computed u == 0")
|
||||
XCTAssertEqual(err.localizedDescription, "SRP Parameter Error: Computed u == 0")
|
||||
}
|
||||
|
||||
func testInvalidM2Throws() throws {
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Verify before computeM1 must fail
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "123456")) { error in
|
||||
guard case SrpError.verificationFailed(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("not initialized"))
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
_ = try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash)
|
||||
|
||||
// Invalid hex
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "invalid-hex"))
|
||||
|
||||
// Odd length hex
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "abc"))
|
||||
|
||||
// Wrong M2
|
||||
let wrongM2 = SrpFormat.bytesToHex(Data(repeating: 0x99, count: 32))
|
||||
XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: wrongM2)) { error in
|
||||
guard case SrpError.verificationFailed(let m) = error else {
|
||||
XCTFail("Wrong error: \(error)")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(m.contains("Server evidence M2 does not match"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testVersion() {
|
||||
XCTAssertEqual(PortalKitVersion.version, "1.0.0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// Srp6aExchangeTests.swift
|
||||
// PortalKitTests
|
||||
//
|
||||
// Verification of 2048-bit SRP-6a end-to-end key exchange with mock server parameters.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class Srp6aExchangeTests: XCTestCase {
|
||||
func testEndToEndExchangeSuccess() throws {
|
||||
let pin = "654321"
|
||||
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("MOCK_CERT_LEAF_DER".data(using: .utf8)!)
|
||||
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 bound to mock TLS cert hash
|
||||
let clientM1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(clientM1Hex.isEmpty)
|
||||
XCTAssertNotNil(client.sessionKey)
|
||||
|
||||
// Server verifies client's A and M1
|
||||
let verifyResult = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: clientM1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(verifyResult.success, "Server rejected valid client proof")
|
||||
XCTAssertNotNil(verifyResult.M2Hex)
|
||||
XCTAssertNotNil(verifyResult.token)
|
||||
|
||||
// Client verifies server M2
|
||||
XCTAssertNoThrow(
|
||||
try client.verifyServerM2(serverM2Hex: verifyResult.M2Hex!),
|
||||
"Client failed to verify genuine server M2"
|
||||
)
|
||||
}
|
||||
|
||||
func testWrongPinFailsExchange() throws {
|
||||
let actualPin = "123456"
|
||||
let wrongPin = "999999"
|
||||
let mockCertHash = Data(repeating: 0x42, count: 32)
|
||||
|
||||
let server = SrpServerMock(pin: actualPin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
// Client computes M1 with wrong PIN
|
||||
let clientM1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: wrongPin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
// Server verification MUST fail
|
||||
let verifyResult = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: clientM1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertFalse(verifyResult.success)
|
||||
XCTAssertNil(verifyResult.token)
|
||||
XCTAssertNil(verifyResult.M2Hex)
|
||||
XCTAssertTrue(verifyResult.errorMessage?.contains("M1 verification failed") ?? false)
|
||||
}
|
||||
|
||||
func testMultipleRandomExchanges() throws {
|
||||
// Run 5 randomized rounds to verify no modular arithmetic edge cases
|
||||
for round in 1...5 {
|
||||
let pin = String(format: "%06d", round * 111111 % 1000000)
|
||||
let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("CERT_ROUND_\(round)".data(using: .utf8)!)
|
||||
|
||||
let server = SrpServerMock(pin: pin)
|
||||
let client = PortalSrpClient()
|
||||
|
||||
let m1Hex = try client.computeM1(
|
||||
saltHex: server.saltHex,
|
||||
pubBHex: server.pubBHex,
|
||||
pin: pin,
|
||||
tlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
let serverRes = server.verifyClient(
|
||||
pubAHex: client.pubAHex,
|
||||
clientM1Hex: m1Hex,
|
||||
serverTlsCertSha256: mockCertHash
|
||||
)
|
||||
|
||||
XCTAssertTrue(serverRes.success, "Round \(round) failed server verification")
|
||||
guard let m2 = serverRes.M2Hex else {
|
||||
XCTFail("Round \(round) missing M2")
|
||||
continue
|
||||
}
|
||||
|
||||
XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: m2), "Round \(round) failed client M2 verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user