Use remote authentication, add key display screen

This commit is contained in:
Christoph Hagen 2022-05-01 14:07:43 +02:00
parent 921b9237f7
commit 2a8833ff20
14 changed files with 378 additions and 149 deletions

View File

@ -20,8 +20,11 @@
E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77327FF95920011CFD2 /* DeviceResponse.swift */; };
E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = E24EE77627FF95C00011CFD2 /* NIOCore */; };
E24EE77927FF95E00011CFD2 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; };
E28DED2D281E840B00259690 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2C281E840B00259690 /* KeyView.swift */; };
E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2E281E8A0500259690 /* SingleKeyView.swift */; };
E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */; };
E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */; };
E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -38,8 +41,11 @@
E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
E24EE77327FF95920011CFD2 /* DeviceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResponse.swift; sourceTree = "<group>"; };
E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = "<group>"; };
E28DED2C281E840B00259690 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = "<group>"; };
E28DED2E281E8A0500259690 /* SingleKeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleKeyView.swift; sourceTree = "<group>"; };
E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteAPI.swift; sourceTree = "<group>"; };
E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Extensions.swift"; sourceTree = "<group>"; };
E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessage.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -76,11 +82,12 @@
E2C5C1D92806FE4A00769EF6 /* API */,
884A45B6279F48C100D6E650 /* SesameApp.swift */,
884A45B8279F48C100D6E650 /* ContentView.swift */,
E28DED2C281E840B00259690 /* KeyView.swift */,
E28DED2E281E8A0500259690 /* SingleKeyView.swift */,
884A45CC27A465F500D6E650 /* Client.swift */,
884A45C827A43D7900D6E650 /* ClientState.swift */,
884A45C4279F4BBE00D6E650 /* KeyManagement.swift */,
884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */,
E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */,
884A45BA279F48C300D6E650 /* Assets.xcassets */,
884A45BC279F48C300D6E650 /* Preview Content */,
);
@ -98,10 +105,12 @@
E2C5C1D92806FE4A00769EF6 /* API */ = {
isa = PBXGroup;
children = (
E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */,
E24EE77327FF95920011CFD2 /* DeviceResponse.swift */,
E24EE77827FF95E00011CFD2 /* Message.swift */,
884A45CE27A5402D00D6E650 /* MessageResult.swift */,
E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */,
E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */,
E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */,
);
path = API;
@ -185,6 +194,7 @@
files = (
884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */,
884A45B9279F48C100D6E650 /* ContentView.swift in Sources */,
E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */,
E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */,
E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */,
884A45CD27A465F500D6E650 /* Client.swift in Sources */,
@ -193,8 +203,10 @@
884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */,
E24EE77927FF95E00011CFD2 /* Message.swift in Sources */,
884A45C927A43D7900D6E650 /* ClientState.swift in Sources */,
E28DED2D281E840B00259690 /* KeyView.swift in Sources */,
884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */,
884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */,
E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -16,6 +16,11 @@ struct DeviceResponse {
.init(event: .deviceNotConnected)
}
/// Shorthand property for a connected event.
static var deviceConnected: DeviceResponse {
.init(event: .deviceConnected)
}
/// Shorthand property for an unexpected socket event.
static var unexpectedSocketEvent: DeviceResponse {
.init(event: .unexpectedSocketEvent)

View File

@ -101,6 +101,10 @@ extension Message {
mac + content.encoded
}
var bytes: [UInt8] {
Array(encoded)
}
/**
Create a message from received bytes.
- Parameter data: The sequence of bytes

View File

@ -38,6 +38,9 @@ enum MessageResult: UInt8 {
/// Another message is being processed by the device
case operationInProgress = 14
/// The device is connected
case deviceConnected = 15
}
extension MessageResult: CustomStringConvertible {
@ -66,6 +69,8 @@ extension MessageResult: CustomStringConvertible {
return "The device did not respond"
case .operationInProgress:
return "Another operation is in progress"
case .deviceConnected:
return "The device is connected"
}
}
}

View File

@ -0,0 +1,51 @@
import Foundation
import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
struct ServerMessage {
static let authTokenSize = SHA256.byteCount
static let length = authTokenSize + Message.length
let authToken: Data
let message: Message
init(authToken: Data, message: Message) {
self.authToken = authToken
self.message = message
}
/**
Decode a message from a byte buffer.
The buffer must contain at least `ServerMessage.length` bytes, or it will return `nil`.
- Parameter buffer: The buffer containing the bytes.
*/
init?(decodeFrom buffer: ByteBuffer) {
guard let data = buffer.getBytes(at: 0, length: ServerMessage.length) else {
return nil
}
self.authToken = Data(data.prefix(ServerMessage.authTokenSize))
self.message = Message(decodeFrom: Data(data.dropFirst(ServerMessage.authTokenSize)))
}
var encoded: Data {
authToken + message.encoded
}
static func token(from buffer: ByteBuffer) -> Data? {
guard buffer.readableBytes == authTokenSize else {
return nil
}
guard let bytes = buffer.getBytes(at: 0, length: authTokenSize) else {
return nil
}
return Data(bytes)
}
}

View File

@ -10,41 +10,20 @@ struct Client {
init(server: URL) {
self.server = server
}
func deviceStatus(authToken: Data) async -> ClientState {
await send(path: .getDeviceStatus, data: authToken).state
}
private enum RequestReponse: Error {
case requestFailed
case unknownResponseData(Data)
case unknownResponseString(String)
case success(UInt8)
func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) {
let serverMessage = ServerMessage(authToken: authToken, message: message)
return await send(path: .postMessage, data: serverMessage.encoded)
}
func deviceStatus() async -> ClientState {
let url = server.appendingPathComponent(RouteAPI.getDeviceStatus.rawValue)
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let response = await integerReponse(to: request)
switch response {
case .requestFailed:
return .deviceNotAvailable(.serverNotReached)
case .unknownResponseData(let data):
return .internalError("Unknown status (\(data.count) bytes)")
case .unknownResponseString(let string):
return .internalError("Unknown status (\(string.prefix(15)))")
case .success(let int):
switch int {
case 0:
return .deviceNotAvailable(.deviceDisconnected)
case 1:
return .ready
default:
return .internalError("Invalid status: \(int)")
}
}
}
func send(_ message: Message) async throws -> (state: ClientState, response: Message?) {
let url = server.appendingPathComponent(RouteAPI.postMessage.rawValue)
private func send(path: RouteAPI, data: Data) async -> (state: ClientState, response: Message?) {
let url = server.appendingPathComponent(path.rawValue)
var request = URLRequest(url: url)
request.httpBody = message.encoded
request.httpBody = data
request.httpMethod = "POST"
guard let data = await fulfill(request) else {
return (.deviceNotAvailable(.serverNotReached), nil)
@ -81,22 +60,6 @@ struct Client {
return nil
}
}
private func integerReponse(to request: URLRequest) async -> RequestReponse {
guard let data = await fulfill(request) else {
return .requestFailed
}
guard let string = String(data: data, encoding: .utf8) else {
print("Unexpected device status data: \([UInt8](data))")
return .unknownResponseData(data)
}
guard let int = UInt8(string) else {
print("Unexpected device status '\(string)'")
return .unknownResponseString(string)
}
return .success(int)
}
}
class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate {

View File

@ -93,16 +93,13 @@ enum ClientState {
self = .deviceNotAvailable(.deviceDisconnected)
case .operationInProgress:
self = .waitingForResponse
case .deviceConnected:
self = .ready
}
}
var actionText: String {
switch self {
case .noKeyAvailable:
return "Create key"
default:
return "Unlock"
}
"Unlock"
}
var requiresDescription: Bool {
@ -137,7 +134,7 @@ enum ClientState {
var allowsAction: Bool {
switch self {
case .requestingStatus, .deviceNotAvailable, .waitingForResponse:
case .requestingStatus, .deviceNotAvailable, .waitingForResponse, .noKeyAvailable:
return false
default:
return true

View File

@ -7,6 +7,9 @@ struct ContentView: View {
@AppStorage("counter")
var nextMessageCounter: Int = 0
@State
var keyManager = KeyManagement()
@State
var state: ClientState = .noKeyAvailable
@ -20,6 +23,12 @@ struct ContentView: View {
@State
private var responseTime: Date? = nil
@State
private var showKeySheet = false
@State
private var showHistorySheet = false
var isPerformingRequests: Bool {
hasActiveRequest ||
state == .waitingForResponse
@ -28,7 +37,7 @@ struct ContentView: View {
var buttonBackground: Color {
state.allowsAction ?
.white.opacity(0.2) :
.gray.opacity(0.2)
.black.opacity(0.2)
}
let buttonBorderWidth: CGFloat = 3
@ -39,16 +48,44 @@ struct ContentView: View {
private let buttonWidth: CGFloat = 250
private let smallButtonHeight: CGFloat = 50
private let smallButtonWidth: CGFloat = 120
private let smallButtonBorderWidth: CGFloat = 1
var body: some View {
GeometryReader { geo in
VStack(spacing: 20) {
HStack {
Button("History", action: { showHistorySheet = true })
.frame(width: smallButtonWidth,
height: smallButtonHeight)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonHeight / 2)
.overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white))
.foregroundColor(.white)
.font(.title2)
.padding()
Spacer()
Button("Keys", action: { showKeySheet = true })
.frame(width: smallButtonWidth,
height: smallButtonHeight)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonHeight / 2)
.overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white))
.foregroundColor(.white)
.font(.title2)
.padding()
}
Spacer()
if state.requiresDescription {
Text(state.description)
.padding()
}
Button(state.actionText, action: mainButtonPressed)
.frame(width: buttonWidth, height: buttonWidth, alignment: .center)
.frame(width: buttonWidth,
height: buttonWidth)
.background(buttonBackground)
.cornerRadius(buttonWidth / 2)
.overlay(RoundedRectangle(cornerRadius: buttonWidth / 2).stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor))
@ -57,8 +94,9 @@ struct ContentView: View {
.disabled(!state.allowsAction)
.padding(.bottom, (geo.size.width-buttonWidth) / 2)
}
.background(state.color)
.onAppear {
if KeyManagement.hasKey {
if keyManager.hasAllKeys {
state = .requestingStatus
}
startRegularStatusUpdates()
@ -67,20 +105,19 @@ struct ContentView: View {
endRegularStatusUpdates()
}
.frame(width: geo.size.width, height: geo.size.height)
.background(state.color)
.animation(.easeInOut, value: state.color)
.sheet(isPresented: $showKeySheet) {
KeyView(keyManager: $keyManager)
}
}
}
func mainButtonPressed() {
guard let key = KeyManagement.key?.remote else {
generateKey()
guard let key = keyManager.get(.remoteKey),
let token = keyManager.get(.authToken)?.data else {
return
}
sendMessage(using: key)
}
func sendMessage(using key: SymmetricKey) {
let count = UInt32(nextMessageCounter)
let now = Date()
let content = Message.Content(
@ -90,7 +127,7 @@ struct ContentView: View {
state = .waitingForResponse
print("Sending message \(count)")
Task {
let (newState, message) = try await server.send(message)
let (newState, message) = await server.send(message, authToken: token)
responseTime = now
state = newState
if let message = message {
@ -100,7 +137,7 @@ struct ContentView: View {
}
private func processResponse(_ message: Message, sendTime: Date) {
guard let key = KeyManagement.key?.device else {
guard let key = keyManager.get(.deviceKey) else {
return
}
guard message.isValid(using: key) else {
@ -115,9 +152,13 @@ struct ContentView: View {
let time1 = deviceTime.timeIntervalSince(sendTime)
let time2 = now.timeIntervalSince(deviceTime)
if time1 < 0 {
print("Device time behind by at least \(Int(-time1 * 1000)) ms behind")
print("Device time behind by at least \(Int(-time1 * 1000)) ms")
print("Device: \(deviceTime)")
print("Remote: \(now)")
} else if time2 < 0 {
print("Device time behind by at least \(Int(-time2 * 1000)) ms ahead")
print("Device time ahead by at least \(Int(-time2 * 1000)) ms")
print("Device: \(deviceTime)")
print("Remote: \(now)")
} else {
print("Device time synchronized")
}
@ -139,13 +180,16 @@ struct ContentView: View {
}
func checkDeviceStatus(_ timer: Timer) {
guard let authToken = keyManager.get(.authToken) else {
return
}
guard !hasActiveRequest else {
return
}
hasActiveRequest = true
print("Checking device status")
Task {
let newState = await server.deviceStatus()
let newState = await server.deviceStatus(authToken: authToken.data)
hasActiveRequest = false
switch state {
case .noKeyAvailable:
@ -173,16 +217,6 @@ struct ContentView: View {
}
}
}
func generateKey() {
print("Regenerate key")
KeyManagement.generateNewKeys()
state = .requestingStatus
}
func shareKey() {
}
}
struct ContentView_Previews: PreviewProvider {

View File

@ -2,95 +2,154 @@ import Foundation
import CryptoKit
import SwiftUI
final class KeyManagement {
extension KeyManagement {
static let tag = "com.ch.sesame.key".data(using: .utf8)!
enum KeyType: String, Identifiable, CaseIterable {
private static let label = "sesame"
case deviceKey = "sesame-device"
case remoteKey = "sesame-remote"
case authToken = "sesame-remote-auth"
private static let keyType = kSecAttrKeyTypeEC
var id: String {
rawValue
}
private static let keyClass = kSecAttrKeyClassSymmetric
var displayName: String {
switch self {
case .deviceKey:
return "Device Key"
case .remoteKey:
return "Remote Key"
case .authToken:
return "Authentication Token"
}
}
private static let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: "account",
kSecAttrServer as String: "christophhagen.de",
kSecAttrLabel as String: "sesame"]
var keyLength: SymmetricKeySize {
.bits256
}
}
}
private static func loadKeys() -> Data? {
var query = query
extension KeyManagement.KeyType: CustomStringConvertible {
var description: String {
displayName
}
}
private struct KeyChain {
private let keyType = kSecAttrKeyTypeEC
private let keyClass = kSecAttrKeyClassSymmetric
private let domain: String
init(domain: String) {
self.domain = domain
}
private func baseQuery(for type: KeyManagement.KeyType) -> [String : Any] {
[kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: type.rawValue,
kSecAttrServer as String: domain]
}
func save(_ type: KeyManagement.KeyType, _ key: SymmetricKey) {
var query = baseQuery(for: type)
query[kSecValueData as String] = key.data
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Failed to store \(type): \(status)")
return
}
print("\(type) saved to keychain")
}
func load(_ type: KeyManagement.KeyType) -> SymmetricKey? {
var query = baseQuery(for: type)
query[kSecReturnData as String] = kCFBooleanTrue
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else {
print("Failed to get key: \(status)")
print("Failed to get \(type): \(status)")
return nil
}
let key = item as! CFData
print("Key loaded from keychain")
return key as Data
print("\(type) loaded from keychain")
return SymmetricKey(data: key as Data)
}
private static func deleteKeys() {
let status = SecItemDelete(query as CFDictionary)
func delete(_ type: KeyManagement.KeyType) {
let status = SecItemDelete(baseQuery(for: type) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
print("Failed to remove key: \(status)")
print("Failed to remove \(type): \(status)")
return
}
print("Key removed from keychain")
print("\(type) removed from keychain")
}
private static func saveKeys(_ data: Data) {
var query = query
query[kSecValueData as String] = data
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Failed to store key: \(status)")
return
}
print("Key saved to keychain")
}
private static var keyData: Data? = loadKeys() {
didSet {
guard let data = keyData else {
deleteKeys()
return
}
saveKeys(data)
}
}
static var hasKey: Bool {
key != nil
}
private(set) static var key: (device: SymmetricKey, remote: SymmetricKey)? {
get {
guard let data = keyData else {
return nil
}
let device = SymmetricKey(data: data.prefix(32))
let remote = SymmetricKey(data: data.advanced(by: 32))
return (device, remote)
}
set {
guard let key = newValue else {
keyData = nil
return
}
keyData = key.device.data + key.remote.data
}
}
static func generateNewKeys() {
let device = SymmetricKey(size: .bits256)
let remote = SymmetricKey(size: .bits256)
key = (device, remote)
print("New keys:")
print("Device: \(device.data.hexEncoded)")
print("Remote: \(remote.data.hexEncoded)")
func has(_ type: KeyManagement.KeyType) -> Bool {
load(type) != nil
}
}
final class KeyManagement: ObservableObject {
private let keyChain: KeyChain
@Published
private(set) var hasRemoteKey = false
@Published
private(set) var hasDeviceKey = false
@Published
private(set) var hasAuthToken = false
var hasAllKeys: Bool {
hasRemoteKey && hasDeviceKey && hasAuthToken
}
init() {
self.keyChain = KeyChain(domain: "christophhagen.de")
updateKeyStates()
}
func has(_ type: KeyType) -> Bool {
switch type {
case .deviceKey:
return hasDeviceKey
case .remoteKey:
return hasRemoteKey
case .authToken:
return hasAuthToken
}
}
func get(_ type: KeyType) -> SymmetricKey? {
keyChain.load(type)
}
func delete(_ type: KeyType) {
keyChain.delete(type)
updateKeyStates()
}
func generate(_ type: KeyType) {
let key = SymmetricKey(size: type.keyLength)
if keyChain.has(type) {
keyChain.delete(type)
}
keyChain.save(type, key)
updateKeyStates()
}
private func updateKeyStates() {
self.hasRemoteKey = keyChain.has(.remoteKey)
self.hasDeviceKey = keyChain.has(.deviceKey)
self.hasAuthToken = keyChain.has(.authToken)
}
}

25
Sesame/KeyView.swift Normal file
View File

@ -0,0 +1,25 @@
import SwiftUI
struct KeyView: View {
@Binding
var keyManager: KeyManagement
var body: some View {
GeometryReader { geo in
VStack(alignment: .leading, spacing: 16) {
ForEach(KeyManagement.KeyType.allCases) { keyType in
SingleKeyView(
keyManager: $keyManager,
type: keyType)
}
}.padding()
}
}
}
struct KeyView_Previews: PreviewProvider {
static var previews: some View {
KeyView(keyManager: .constant(KeyManagement()))
}
}

View File

@ -0,0 +1,54 @@
import SwiftUI
struct SingleKeyView: View {
@State
private var needRefresh = false
@Binding
var keyManager: KeyManagement
let type: KeyManagement.KeyType
private var generateText: String {
hasKey ? "Generate" : "Regenerate"
}
var hasKey: Bool {
keyManager.has(type)
}
var content: String {
keyManager.get(type)?.displayString ?? "-"
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(type.displayName)
.bold()
Text(needRefresh ? content : content)
.font(.system(.body, design: .monospaced))
HStack() {
Button(generateText) {
keyManager.generate(type)
needRefresh.toggle()
}
.padding()
Button("Copy") {
UIPasteboard.general.string = content
}
.disabled(!hasKey)
.padding()
Spacer()
}
}
}
}
struct SingleKeyView_Previews: PreviewProvider {
static var previews: some View {
SingleKeyView(
keyManager: .constant(KeyManagement()),
type: .deviceKey)
}
}

View File

@ -10,6 +10,10 @@ extension SymmetricKey {
var base64: String {
data.base64EncodedString()
}
var displayString: String {
data.hexEncoded.uppercased().split(by: 4).joined(separator: " ")
}
var codeString: String {
" {" +
@ -19,3 +23,19 @@ extension SymmetricKey {
"},"
}
}
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) }
}
}