Challenge-response, SwiftData, new UI
This commit is contained in:
18
Sesame/Common/ActiveRequestType.swift
Normal file
18
Sesame/Common/ActiveRequestType.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
enum RequestType {
|
||||
case challenge
|
||||
case unlock
|
||||
}
|
||||
|
||||
extension RequestType: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .challenge:
|
||||
return "Challenge"
|
||||
case .unlock:
|
||||
return "Unlock"
|
||||
}
|
||||
}
|
||||
}
|
@ -3,91 +3,100 @@ import CryptoKit
|
||||
|
||||
final class Client {
|
||||
|
||||
// TODO: Use or delete
|
||||
private let delegate = NeverCacheDelegate()
|
||||
private let localRequestRoute = "message"
|
||||
|
||||
private let urlMessageParameter = "m"
|
||||
|
||||
init() {}
|
||||
|
||||
func deviceStatus(authToken: Data, server: String) async -> ClientState {
|
||||
await send(path: .getDeviceStatus, server: server, data: authToken).state
|
||||
}
|
||||
|
||||
func sendMessageOverLocalNetwork(_ message: Message, server: String) async -> (state: ClientState, response: Message?) {
|
||||
func send(_ message: Message, to url: String, through route: TransmissionType, using keys: KeySet) async -> ServerResponse {
|
||||
let sentTime = Date.now
|
||||
let signedMessage = message.authenticate(using: keys.remote)
|
||||
let response: Message
|
||||
switch route {
|
||||
case .throughServer:
|
||||
response = await send(signedMessage, toServerUrl: url, authenticateWith: keys.server, verifyUsing: keys.device)
|
||||
|
||||
case .overLocalWifi:
|
||||
response = await send(signedMessage, toLocalDeviceUrl: url, verifyUsing: keys.device)
|
||||
}
|
||||
let receivedTime = Date.now
|
||||
// Create best guess for creation of challenge.
|
||||
let roundTripTime = receivedTime.timeIntervalSince(sentTime)
|
||||
let serverChallenge = ServerChallenge(
|
||||
creationDate: sentTime.addingTimeInterval(roundTripTime / 2),
|
||||
message: response)
|
||||
|
||||
// Validate message content
|
||||
guard response.result == .messageAccepted else {
|
||||
print("Failure: \(response)")
|
||||
return (response, nil)
|
||||
}
|
||||
|
||||
guard response.clientChallenge == message.clientChallenge else {
|
||||
print("Invalid client challenge: \(response)")
|
||||
return (response.with(result: .invalidClientChallengeFromDevice), nil)
|
||||
}
|
||||
return (response, serverChallenge)
|
||||
}
|
||||
|
||||
|
||||
private func send(_ message: SignedMessage, toLocalDeviceUrl server: String, verifyUsing deviceKey: SymmetricKey) async -> Message {
|
||||
let data = message.encoded.hexEncoded
|
||||
guard let url = URL(string: server + "message?m=\(data)") else {
|
||||
return (.internalError("Invalid server url"), nil)
|
||||
guard let url = URL(string: server)?.appendingPathComponent("\(localRequestRoute)?\(urlMessageParameter)=\(data)") else {
|
||||
return message.message.with(result: .serverUrlInvalid)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
return await requestAndDecode(request)
|
||||
request.timeoutInterval = 10
|
||||
return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey)
|
||||
}
|
||||
|
||||
func send(_ message: Message, server: String, authToken: Data) async -> (state: ClientState, response: Message?) {
|
||||
let serverMessage = ServerMessage(authToken: authToken, message: message)
|
||||
return await send(path: .postMessage, server: server, data: serverMessage.encoded)
|
||||
}
|
||||
|
||||
private func send(path: RouteAPI, server: String, data: Data) async -> (state: ClientState, response: Message?) {
|
||||
guard let url = URL(string: server) else {
|
||||
return (.internalError("Invalid server url"), nil)
|
||||
private func send(_ message: SignedMessage, toServerUrl server: String, authenticateWith authToken: Data, verifyUsing deviceKey: SymmetricKey) async -> Message {
|
||||
guard let url = URL(string: server)?.appendingPathComponent(SesameRoute.postMessage.rawValue) else {
|
||||
return message.message.with(result: .serverUrlInvalid)
|
||||
}
|
||||
let fullUrl = url.appendingPathComponent(path.rawValue)
|
||||
return await send(to: fullUrl, data: data)
|
||||
}
|
||||
|
||||
private func send(to url: URL, data: Data) async -> (state: ClientState, response: Message?) {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpBody = data
|
||||
request.httpBody = message.encoded
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 10
|
||||
return await requestAndDecode(request)
|
||||
request.addValue(authToken.hexEncoded, forHTTPHeaderField: SesameHeader.authenticationHeader)
|
||||
return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey)
|
||||
}
|
||||
|
||||
private func requestAndDecode(_ request: URLRequest) async -> (state: ClientState, response: Message?) {
|
||||
guard let data = await fulfill(request) else {
|
||||
return (.deviceNotAvailable(.serverNotReached), nil)
|
||||
private func perform(_ request: URLRequest, inResponseTo message: Message, verifyUsing deviceKey: SymmetricKey) async -> Message {
|
||||
let (response, responseData) = await fulfill(request)
|
||||
guard response == .messageAccepted, let data = responseData else {
|
||||
return message.with(result: response)
|
||||
}
|
||||
guard let byte = data.first else {
|
||||
return (.internalError("Empty response"), nil)
|
||||
guard data.count == SignedMessage.size else {
|
||||
print("[WARN] Received message with \(data.count) bytes (\(Array(data)))")
|
||||
return message.with(result: .invalidMessageSizeFromDevice)
|
||||
}
|
||||
guard let status = MessageResult(rawValue: byte) else {
|
||||
return (.internalError("Invalid message response: \(byte)"), nil)
|
||||
let decodedMessage: SignedMessage
|
||||
do {
|
||||
decodedMessage = try SignedMessage(decodeFrom: data)
|
||||
} catch {
|
||||
return message.with(result: error as! MessageResult)
|
||||
}
|
||||
let result = ClientState(keyResult: status)
|
||||
guard data.count == Message.length + 1 else {
|
||||
if data.count != 1 {
|
||||
print("Device response with only \(data.count) bytes")
|
||||
}
|
||||
return (result, nil)
|
||||
guard decodedMessage.isValid(using: deviceKey) else {
|
||||
return message.with(result: .invalidSignatureFromDevice)
|
||||
}
|
||||
let messageData = Array(data.advanced(by: 1))
|
||||
let message = Message(decodeFrom: messageData)
|
||||
return (result, message)
|
||||
return decodedMessage.message
|
||||
}
|
||||
|
||||
private func fulfill(_ request: URLRequest) async -> Data? {
|
||||
private func fulfill(_ request: URLRequest) async -> (response: MessageResult, data: Data?) {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let code = (response as? HTTPURLResponse)?.statusCode else {
|
||||
print("No response from server")
|
||||
return nil
|
||||
return (.unexpectedUrlResponseType, nil)
|
||||
}
|
||||
guard code == 200 else {
|
||||
print("Invalid server response \(code)")
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
return (.init(httpCode: code), data)
|
||||
} catch {
|
||||
print("Request failed: \(error)")
|
||||
return nil
|
||||
return (.deviceTimedOut, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate {
|
||||
|
||||
func connection(_ connection: NSURLConnection, willCacheResponse cachedResponse: CachedURLResponse) -> CachedURLResponse? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -1,371 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
enum ConnectionError {
|
||||
case serverNotReached
|
||||
case deviceDisconnected
|
||||
}
|
||||
|
||||
extension ConnectionError: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .serverNotReached:
|
||||
return "Server unavailable"
|
||||
case .deviceDisconnected:
|
||||
return "Device disconnected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RejectionCause {
|
||||
case invalidDeviceId
|
||||
case invalidCounter
|
||||
case invalidTime
|
||||
case invalidAuthentication
|
||||
case timeout
|
||||
case missingKey
|
||||
}
|
||||
|
||||
extension RejectionCause: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .invalidDeviceId:
|
||||
return "Invalid device ID"
|
||||
case .invalidCounter:
|
||||
return "Invalid counter"
|
||||
case .invalidTime:
|
||||
return "Invalid time"
|
||||
case .invalidAuthentication:
|
||||
return "Invalid authentication"
|
||||
case .timeout:
|
||||
return "Device not responding"
|
||||
case .missingKey:
|
||||
return "No key to verify message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientState {
|
||||
|
||||
/// There is no key stored locally on the client. A new key must be generated before use.
|
||||
case noKeyAvailable
|
||||
|
||||
/// The device status is being requested
|
||||
case requestingStatus
|
||||
|
||||
/// The remote device is not connected (no socket opened)
|
||||
case deviceNotAvailable(ConnectionError)
|
||||
|
||||
/// The device is connected and ready to receive a message
|
||||
case ready
|
||||
|
||||
/// The message is being transmitted and a response is awaited
|
||||
case waitingForResponse
|
||||
|
||||
/// The transmitted message was rejected (multiple possible reasons)
|
||||
case messageRejected(RejectionCause)
|
||||
|
||||
case responseRejected(RejectionCause)
|
||||
|
||||
/// The device responded that the opening action was started
|
||||
case openSesame
|
||||
|
||||
case internalError(String)
|
||||
|
||||
var canSendKey: Bool {
|
||||
switch self {
|
||||
case .ready, .openSesame, .messageRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
init(keyResult: MessageResult) {
|
||||
switch keyResult {
|
||||
case .messageAuthenticationFailed:
|
||||
self = .messageRejected(.invalidAuthentication)
|
||||
case .messageTimeMismatch:
|
||||
self = .messageRejected(.invalidTime)
|
||||
case .messageCounterInvalid:
|
||||
self = .messageRejected(.invalidCounter)
|
||||
case .deviceTimedOut:
|
||||
self = .messageRejected(.timeout)
|
||||
case .messageAccepted:
|
||||
self = .openSesame
|
||||
case .messageDeviceInvalid:
|
||||
self = .messageRejected(.invalidDeviceId)
|
||||
case .noBodyData, .invalidMessageSize, .textReceived, .unexpectedSocketEvent, .invalidUrlParameter, .invalidResponseAuthentication:
|
||||
print("Unexpected internal error: \(keyResult)")
|
||||
self = .internalError(keyResult.description)
|
||||
case .deviceNotConnected:
|
||||
self = .deviceNotAvailable(.deviceDisconnected)
|
||||
case .operationInProgress:
|
||||
self = .waitingForResponse
|
||||
case .deviceConnected:
|
||||
self = .ready
|
||||
}
|
||||
}
|
||||
|
||||
var actionText: String {
|
||||
switch self {
|
||||
case .noKeyAvailable:
|
||||
return "No key"
|
||||
case .requestingStatus:
|
||||
return "Checking..."
|
||||
case .deviceNotAvailable(let connectionError):
|
||||
switch connectionError {
|
||||
case .serverNotReached:
|
||||
return "Server not found"
|
||||
case .deviceDisconnected:
|
||||
return "Device disconnected"
|
||||
}
|
||||
case .ready:
|
||||
return "Unlock"
|
||||
case .waitingForResponse:
|
||||
return "Unlocking..."
|
||||
case .messageRejected(let rejectionCause):
|
||||
switch rejectionCause {
|
||||
case .invalidDeviceId:
|
||||
return "Invalid device ID"
|
||||
case .invalidCounter:
|
||||
return "Invalid counter"
|
||||
case .invalidTime:
|
||||
return "Invalid timestamp"
|
||||
case .invalidAuthentication:
|
||||
return "Invalid signature"
|
||||
case .timeout:
|
||||
return "Device not responding"
|
||||
case .missingKey:
|
||||
return "Device key missing"
|
||||
}
|
||||
case .responseRejected(let rejectionCause):
|
||||
switch rejectionCause {
|
||||
case .invalidDeviceId:
|
||||
return "Invalid device id (response)"
|
||||
case .invalidCounter:
|
||||
return "Invalid counter (response)"
|
||||
case .invalidTime:
|
||||
return "Invalid time (response)"
|
||||
case .invalidAuthentication:
|
||||
return "Invalid signature (response)"
|
||||
case .timeout:
|
||||
return "Timed out (response)"
|
||||
case .missingKey:
|
||||
return "Missing key (response)"
|
||||
}
|
||||
case .openSesame:
|
||||
return "Unlocked"
|
||||
case .internalError(let string):
|
||||
return string
|
||||
}
|
||||
}
|
||||
|
||||
var requiresDescription: Bool {
|
||||
switch self {
|
||||
case .deviceNotAvailable, .messageRejected, .internalError, .responseRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .noKeyAvailable:
|
||||
return Color(red: 50/255, green: 50/255, blue: 50/255)
|
||||
case .deviceNotAvailable:
|
||||
return Color(red: 150/255, green: 90/255, blue: 90/255)
|
||||
case .messageRejected, .responseRejected:
|
||||
return Color(red: 160/255, green: 30/255, blue: 30/255)
|
||||
case .internalError:
|
||||
return Color(red: 100/255, green: 0/255, blue: 0/255)
|
||||
case .ready:
|
||||
return Color(red: 115/255, green: 140/255, blue: 90/255)
|
||||
case .requestingStatus, .waitingForResponse:
|
||||
return Color(red: 160/255, green: 170/255, blue: 110/255)
|
||||
case .openSesame:
|
||||
return Color(red: 65/255, green: 110/255, blue: 60/255)
|
||||
}
|
||||
}
|
||||
|
||||
var allowsAction: Bool {
|
||||
switch self {
|
||||
case .noKeyAvailable, .waitingForResponse:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientState: Equatable {
|
||||
|
||||
}
|
||||
|
||||
extension ClientState: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noKeyAvailable:
|
||||
return "No key set."
|
||||
case .requestingStatus:
|
||||
return "Checking device status"
|
||||
case .deviceNotAvailable(let status):
|
||||
return status.description
|
||||
case .ready:
|
||||
return "Ready"
|
||||
case .waitingForResponse:
|
||||
return "Unlocking..."
|
||||
case .messageRejected(let cause):
|
||||
return cause.description
|
||||
case .openSesame:
|
||||
return "Unlocked"
|
||||
case .internalError(let e):
|
||||
return "Error: \(e)"
|
||||
case .responseRejected(let cause):
|
||||
switch cause {
|
||||
case .invalidAuthentication:
|
||||
return "Device message not authenticated"
|
||||
default:
|
||||
return cause.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
|
||||
extension ClientState {
|
||||
|
||||
var encoded: Data {
|
||||
Data([code])
|
||||
}
|
||||
|
||||
var code: UInt8 {
|
||||
switch self {
|
||||
case .noKeyAvailable:
|
||||
return 1
|
||||
case .requestingStatus:
|
||||
return 2
|
||||
case .deviceNotAvailable(let connectionError):
|
||||
switch connectionError {
|
||||
case .serverNotReached:
|
||||
return 3
|
||||
case .deviceDisconnected:
|
||||
return 4
|
||||
}
|
||||
case .ready:
|
||||
return 5
|
||||
case .waitingForResponse:
|
||||
return 6
|
||||
case .messageRejected(let rejectionCause):
|
||||
switch rejectionCause {
|
||||
case .invalidDeviceId:
|
||||
return 19
|
||||
case .invalidCounter:
|
||||
return 7
|
||||
case .invalidTime:
|
||||
return 8
|
||||
case .invalidAuthentication:
|
||||
return 9
|
||||
case .timeout:
|
||||
return 10
|
||||
case .missingKey:
|
||||
return 11
|
||||
}
|
||||
case .responseRejected(let rejectionCause):
|
||||
switch rejectionCause {
|
||||
case .invalidCounter:
|
||||
return 12
|
||||
case .invalidTime:
|
||||
return 13
|
||||
case .invalidAuthentication:
|
||||
return 14
|
||||
case .timeout:
|
||||
return 15
|
||||
case .missingKey:
|
||||
return 16
|
||||
case .invalidDeviceId:
|
||||
return 20
|
||||
}
|
||||
case .openSesame:
|
||||
return 17
|
||||
case .internalError:
|
||||
return 18
|
||||
}
|
||||
}
|
||||
|
||||
init(code: UInt8) {
|
||||
switch code {
|
||||
case 1:
|
||||
self = .noKeyAvailable
|
||||
case 2:
|
||||
self = .requestingStatus
|
||||
case 3:
|
||||
self = .deviceNotAvailable(.serverNotReached)
|
||||
case 4:
|
||||
self = .deviceNotAvailable(.deviceDisconnected)
|
||||
case 5:
|
||||
self = .ready
|
||||
case 6:
|
||||
self = .waitingForResponse
|
||||
case 7:
|
||||
self = .messageRejected(.invalidCounter)
|
||||
case 8:
|
||||
self = .messageRejected(.invalidTime)
|
||||
case 9:
|
||||
self = .messageRejected(.invalidAuthentication)
|
||||
case 10:
|
||||
self = .messageRejected(.timeout)
|
||||
case 11:
|
||||
self = .messageRejected(.missingKey)
|
||||
case 12:
|
||||
self = .responseRejected(.invalidCounter)
|
||||
case 13:
|
||||
self = .responseRejected(.invalidTime)
|
||||
case 14:
|
||||
self = .responseRejected(.invalidAuthentication)
|
||||
case 15:
|
||||
self = .responseRejected(.timeout)
|
||||
case 16:
|
||||
self = .responseRejected(.missingKey)
|
||||
case 17:
|
||||
self = .openSesame
|
||||
case 18:
|
||||
self = .internalError("")
|
||||
case 19:
|
||||
self = .messageRejected(.invalidDeviceId)
|
||||
case 20:
|
||||
self = .responseRejected(.invalidDeviceId)
|
||||
default:
|
||||
self = .internalError("Unknown code \(code)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ClientState {
|
||||
|
||||
@available(iOS 16, *)
|
||||
var symbol: SFSymbol {
|
||||
switch self {
|
||||
case .deviceNotAvailable:
|
||||
return .wifiExclamationmark
|
||||
case .internalError:
|
||||
return .applewatchSlash
|
||||
case .noKeyAvailable:
|
||||
return .lockTrianglebadgeExclamationmark
|
||||
case .openSesame:
|
||||
return .lockOpen
|
||||
case .messageRejected:
|
||||
return .nosign
|
||||
case .responseRejected:
|
||||
return .exclamationmarkTriangle
|
||||
case .requestingStatus, .ready, .waitingForResponse:
|
||||
return .wifiExclamationmark
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,27 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
enum ConnectionStrategy: String, CaseIterable, Identifiable {
|
||||
case local = "Local"
|
||||
case localFirst = "Local first"
|
||||
case remote = "Remote"
|
||||
case remoteFirst = "Remote first"
|
||||
enum ConnectionStrategy: Int, CaseIterable, Identifiable {
|
||||
case local = 0
|
||||
case remote = 1
|
||||
case localFirst = 2
|
||||
case remoteFirst = 3
|
||||
|
||||
var id: Self { self }
|
||||
var id: Int { rawValue }
|
||||
|
||||
var transmissionTypes: [TransmissionType] {
|
||||
switch self {
|
||||
case .local: return [.overLocalWifi]
|
||||
case .localFirst: return [.overLocalWifi, .throughServer]
|
||||
case .remote: return [.throughServer]
|
||||
case .remoteFirst: return [.throughServer, .overLocalWifi]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectionStrategy: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
transmissionTypes.map { $0.displayName }.joined(separator: "+")
|
||||
}
|
||||
}
|
||||
|
14
Sesame/Common/Extensions/Array+Extensions.swift
Normal file
14
Sesame/Common/Extensions/Array+Extensions.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension Array {
|
||||
|
||||
func count(where closure: (Element) -> Bool) -> Int {
|
||||
var result = 0
|
||||
forEach { element in
|
||||
if closure(element) {
|
||||
result += 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
71
Sesame/Common/Extensions/SymmetricKey+Extensions.swift
Normal file
71
Sesame/Common/Extensions/SymmetricKey+Extensions.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
extension SymmetricKey {
|
||||
|
||||
var data: Data {
|
||||
withUnsafeBytes { Data(Array($0)) }
|
||||
}
|
||||
|
||||
var base64: String {
|
||||
data.base64EncodedString()
|
||||
}
|
||||
|
||||
var displayString: String {
|
||||
data.hexEncoded.uppercased().split(by: 4).joined(separator: " ")
|
||||
}
|
||||
|
||||
var codeString: String {
|
||||
" {" +
|
||||
withUnsafeBytes {
|
||||
return Data(Array($0))
|
||||
}.map(String.init).joined(separator: ", ") +
|
||||
"},"
|
||||
}
|
||||
}
|
||||
|
||||
extension SHA256.Digest {
|
||||
|
||||
var hexEncoded: String {
|
||||
Data(map { $0 }).hexEncoded
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
|
||||
func split(by length: Int) -> [String] {
|
||||
var startIndex = self.startIndex
|
||||
var results = [Substring]()
|
||||
|
||||
while startIndex < self.endIndex {
|
||||
let endIndex = self.index(startIndex, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex
|
||||
results.append(self[startIndex..<endIndex])
|
||||
startIndex = endIndex
|
||||
}
|
||||
|
||||
return results.map { String($0) }
|
||||
}
|
||||
}
|
||||
|
||||
let protocolSalt = "CryptoKit Playgrounds Putting It Together".data(using: .utf8)!
|
||||
|
||||
/// Generates an ephemeral key agreement key and performs key agreement to get the shared secret and derive the symmetric encryption key.
|
||||
func encrypt(_ data: Data, to theirEncryptionKey: Curve25519.KeyAgreement.PublicKey, signedBy ourSigningKey: Curve25519.Signing.PrivateKey) throws ->
|
||||
(ephemeralPublicKeyData: Data, ciphertext: Data, signature: Data) {
|
||||
let ephemeralKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
let ephemeralPublicKey = ephemeralKey.publicKey.rawRepresentation
|
||||
|
||||
let sharedSecret = try ephemeralKey.sharedSecretFromKeyAgreement(with: theirEncryptionKey)
|
||||
|
||||
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self,
|
||||
salt: protocolSalt,
|
||||
sharedInfo: ephemeralPublicKey +
|
||||
theirEncryptionKey.rawRepresentation +
|
||||
ourSigningKey.publicKey.rawRepresentation,
|
||||
outputByteCount: 32)
|
||||
|
||||
let ciphertext = try ChaChaPoly.seal(data, using: symmetricKey).combined
|
||||
let signature = try ourSigningKey.signature(for: ciphertext + ephemeralPublicKey + theirEncryptionKey.rawRepresentation)
|
||||
|
||||
return (ephemeralPublicKey, ciphertext, signature)
|
||||
}
|
9
Sesame/Common/Extensions/Text+Extensions.swift
Normal file
9
Sesame/Common/Extensions/Text+Extensions.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Text {
|
||||
|
||||
init(display: CustomStringConvertible) {
|
||||
self.init(display.description)
|
||||
}
|
||||
}
|
8
Sesame/Common/Extensions/UInt32+Random.swift
Normal file
8
Sesame/Common/Extensions/UInt32+Random.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension UInt32 {
|
||||
|
||||
static func random() -> UInt32 {
|
||||
random(in: UInt32.min...UInt32.max)
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import CBORCoding
|
||||
|
||||
/*
|
||||
class HistoryManagerBase: ObservableObject {
|
||||
|
||||
@Published
|
||||
@ -141,3 +142,4 @@ final class HistoryManagerMock: HistoryManagerBase, HistoryManagerProtocol {
|
||||
return true
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -129,9 +129,8 @@ final class KeyManagement: ObservableObject {
|
||||
@Published
|
||||
private(set) var hasAuthToken = false
|
||||
|
||||
var hasAllKeys: Bool {
|
||||
hasRemoteKey && hasDeviceKey && hasAuthToken
|
||||
}
|
||||
@Published
|
||||
private(set) var hasAllKeys = false
|
||||
|
||||
init() {
|
||||
self.keyChain = KeyChain(domain: "christophhagen.de")
|
||||
@ -189,5 +188,6 @@ final class KeyManagement: ObservableObject {
|
||||
self.hasRemoteKey = keyChain.has(.remoteKey)
|
||||
self.hasDeviceKey = keyChain.has(.deviceKey)
|
||||
self.hasAuthToken = keyChain.has(.authToken)
|
||||
self.hasAllKeys = hasRemoteKey && hasDeviceKey && hasAuthToken
|
||||
}
|
||||
}
|
||||
|
12
Sesame/Common/PendingOperation.swift
Normal file
12
Sesame/Common/PendingOperation.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct PendingOperation {
|
||||
|
||||
let route: TransmissionType
|
||||
|
||||
let operation: RequestType
|
||||
}
|
||||
|
||||
extension PendingOperation: Equatable {
|
||||
|
||||
}
|
229
Sesame/Common/RequestCoordinator.swift
Normal file
229
Sesame/Common/RequestCoordinator.swift
Normal file
@ -0,0 +1,229 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
final class RequestCoordinator: ObservableObject {
|
||||
|
||||
@Published
|
||||
var serverChallenge: ServerChallenge? = nil
|
||||
|
||||
@Published
|
||||
var state: MessageResult = .noKeyAvailable
|
||||
|
||||
@Published
|
||||
private var timer: Timer?
|
||||
|
||||
@Published
|
||||
var pendingRequests: [PendingOperation] = []
|
||||
|
||||
@Published
|
||||
var activeRequest: PendingOperation?
|
||||
|
||||
@Published
|
||||
var keyManager = KeyManagement()
|
||||
|
||||
@AppStorage("server")
|
||||
var serverPath: String = "https://christophhagen.de/sesame/"
|
||||
|
||||
@AppStorage("localIP")
|
||||
var localAddress: String = "192.168.178.104/"
|
||||
|
||||
@AppStorage("connectionType")
|
||||
var connectionType: ConnectionStrategy = .remoteFirst
|
||||
|
||||
let modelContext: ModelContext
|
||||
|
||||
init(modelContext: ModelContext) {
|
||||
self.modelContext = modelContext
|
||||
if keyManager.hasAllKeys {
|
||||
self.state = .notChecked
|
||||
}
|
||||
}
|
||||
|
||||
let client = Client()
|
||||
|
||||
var needsNewServerChallenge: Bool {
|
||||
serverChallenge?.isExpired ?? true
|
||||
}
|
||||
|
||||
@Published
|
||||
var isPerformingRequest: Bool = false
|
||||
|
||||
func startUnlock() {
|
||||
addOperations(.challenge, .unlock)
|
||||
}
|
||||
|
||||
func startChallenge() {
|
||||
addOperations(.challenge)
|
||||
}
|
||||
|
||||
private func addOperations(_ operations: RequestType...) {
|
||||
#warning("Only perform challenge when doing unlock? Remove code complexity")
|
||||
// Just add all operations for an unlock
|
||||
// For every completed operation, the unnecessary ones will be removed without executing them
|
||||
let operations = connectionType.transmissionTypes.map { route in
|
||||
operations.map { PendingOperation(route: route, operation: $0) }
|
||||
}.joined()
|
||||
|
||||
pendingRequests.append(contentsOf: operations)
|
||||
continueRequests()
|
||||
}
|
||||
|
||||
private func continueRequests() {
|
||||
guard activeRequest == nil else {
|
||||
return
|
||||
}
|
||||
guard !pendingRequests.isEmpty else {
|
||||
self.isPerformingRequest = false
|
||||
return
|
||||
}
|
||||
let activeRequest = pendingRequests.removeFirst()
|
||||
self.activeRequest = activeRequest
|
||||
self.isPerformingRequest = true
|
||||
Task {
|
||||
await process(request: activeRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private func process(request: PendingOperation) async {
|
||||
let startTime = Date.now
|
||||
let (success, response, challenge) = await self.start(request)
|
||||
let endTime = Date.now
|
||||
let roundTripTime = endTime.timeIntervalSince(startTime)
|
||||
|
||||
if let s = challenge?.message {
|
||||
print("\(s) took \(Int(roundTripTime * 1000)) ms")
|
||||
} else {
|
||||
print("\(request.operation.description) took \(Int(roundTripTime * 1000)) ms")
|
||||
}
|
||||
|
||||
if request.operation == .unlock, let response {
|
||||
print("Saving history item")
|
||||
let item = HistoryItem(message: response, startDate: startTime, route: request.route, finishDate: endTime)
|
||||
modelContext.insert(item)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.filterPendingRequests(after: request, success: success, hasChallenge: challenge != nil)
|
||||
if let response {
|
||||
self.state = response.result
|
||||
}
|
||||
if let challenge {
|
||||
self.serverChallenge = challenge
|
||||
}
|
||||
self.activeRequest = nil
|
||||
self.continueRequests()
|
||||
}
|
||||
}
|
||||
|
||||
private func filterPendingRequests(after operation: PendingOperation, success: Bool, hasChallenge: Bool) {
|
||||
if success {
|
||||
// Filter all unlocks
|
||||
if operation.operation == .unlock {
|
||||
// Successful unlock means no need for next challenge or unlocks, so remove all
|
||||
self.pendingRequests = []
|
||||
} else {
|
||||
// Successful challenge means no need for additional challenges, but keep unlocks
|
||||
self.pendingRequests = pendingRequests.filter { $0.operation != .challenge }
|
||||
}
|
||||
} else {
|
||||
// Filter all operations with the same route for connection errors
|
||||
// And with type, depending on error?
|
||||
}
|
||||
}
|
||||
|
||||
private func start(_ operation: PendingOperation) async -> OptionalServerResponse {
|
||||
switch operation.operation {
|
||||
case .challenge:
|
||||
if let serverChallenge, !serverChallenge.isExpired {
|
||||
return (true, serverChallenge.message, serverChallenge)
|
||||
}
|
||||
return await performChallenge(route: operation.route)
|
||||
|
||||
case .unlock:
|
||||
guard let serverChallenge, !serverChallenge.isExpired else {
|
||||
return (false, nil, nil)
|
||||
}
|
||||
return await performUnlock(with: serverChallenge.message, route: operation.route)
|
||||
}
|
||||
}
|
||||
|
||||
private func performChallenge(route: TransmissionType) async -> OptionalServerResponse {
|
||||
let initialMessage = Message.initial()
|
||||
let (result, challenge) = await send(initialMessage, route: route)
|
||||
guard let message = challenge?.message else {
|
||||
return (false, result, nil)
|
||||
}
|
||||
// Can't get here without the message being accepted
|
||||
guard message.messageType == .challenge else {
|
||||
print("Invalid message type for challenge: \(message)")
|
||||
return (false, result.with(result: .invalidMessageTypeFromDevice), nil)
|
||||
}
|
||||
return (true, result.with(result: .deviceAvailable), challenge)
|
||||
}
|
||||
|
||||
private func performUnlock(with challenge: Message, route: TransmissionType) async -> OptionalServerResponse {
|
||||
let request = challenge.requestMessage()
|
||||
let (unlockState, responseData) = await send(request, route: route)
|
||||
|
||||
guard let response = responseData?.message else {
|
||||
return (false, unlockState, nil)
|
||||
}
|
||||
switch response.messageType {
|
||||
case .initial, .request:
|
||||
print("Invalid message type for response: \(response)")
|
||||
return (false, response.with(result: .invalidMessageTypeFromDevice), nil)
|
||||
case .challenge:
|
||||
// New challenge received, challenge was expired
|
||||
return (true, unlockState, responseData)
|
||||
case .response:
|
||||
break
|
||||
}
|
||||
|
||||
guard response.serverChallenge == request.serverChallenge else {
|
||||
print("Invalid server challenge for unlock: \(response)")
|
||||
return (false, response.with(result: .invalidServerChallengeFromDevice), nil)
|
||||
}
|
||||
return (true, response.with(result: .unlocked), nil)
|
||||
}
|
||||
|
||||
private func url(for route: TransmissionType) -> String {
|
||||
switch route {
|
||||
case .throughServer:
|
||||
return serverPath
|
||||
case .overLocalWifi:
|
||||
return localAddress
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ message: Message, route: TransmissionType) async -> ServerResponse {
|
||||
guard let keys = keyManager.getAllKeys() else {
|
||||
return (message.with(result: .noKeyAvailable), nil)
|
||||
}
|
||||
let url = url(for: route)
|
||||
return await client.send(message, to: url, through: route, using: keys)
|
||||
}
|
||||
|
||||
func startUpdatingServerChallenge() {
|
||||
guard timer == nil else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] timer in
|
||||
guard let self else {
|
||||
timer.invalidate()
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.startChallenge()
|
||||
}
|
||||
}
|
||||
self.timer!.fire()
|
||||
}
|
||||
}
|
||||
|
||||
func endUpdatingServerChallenge() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
17
Sesame/Common/ServerChallenge.swift
Normal file
17
Sesame/Common/ServerChallenge.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct ServerChallenge {
|
||||
|
||||
private static let challengeExpiryTime: TimeInterval = 25.0
|
||||
|
||||
let creationDate: Date
|
||||
|
||||
let message: Message
|
||||
|
||||
var isExpired: Bool {
|
||||
creationDate.addingTimeInterval(ServerChallenge.challengeExpiryTime) < Date.now
|
||||
}
|
||||
}
|
||||
|
||||
typealias ServerResponse = (result: Message, challenge: ServerChallenge?)
|
||||
typealias OptionalServerResponse = (success: Bool, result: Message?, challenge: ServerChallenge?)
|
42
Sesame/Common/TransmissionType.swift
Normal file
42
Sesame/Common/TransmissionType.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
import SFSafeSymbols
|
||||
|
||||
enum TransmissionType: Int {
|
||||
case throughServer = 0
|
||||
case overLocalWifi = 1
|
||||
}
|
||||
|
||||
extension TransmissionType: Codable {
|
||||
|
||||
}
|
||||
|
||||
extension TransmissionType {
|
||||
|
||||
var symbol: SFSymbol {
|
||||
switch self {
|
||||
case .throughServer: return .network
|
||||
case .overLocalWifi: return .wifi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TransmissionType: CaseIterable {
|
||||
|
||||
}
|
||||
|
||||
extension TransmissionType: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
|
||||
extension TransmissionType {
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .throughServer: return "Mobile"
|
||||
case .overLocalWifi: return "WiFi"
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user