Files
clawdbot/apps/shared/ClawdbotKit/Sources/ClawdbotKit/DeviceIdentity.swift
2026-01-20 13:04:19 +00:00

111 lines
4.0 KiB
Swift

import CryptoKit
import Foundation
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
public var privateKey: String
public var createdAtMs: Int
public init(deviceId: String, publicKey: String, privateKey: String, createdAtMs: Int) {
self.deviceId = deviceId
self.publicKey = publicKey
self.privateKey = privateKey
self.createdAtMs = createdAtMs
}
}
enum DeviceIdentityPaths {
private static let stateDirEnv = "CLAWDBOT_STATE_DIR"
static func stateDirURL() -> URL {
if let raw = getenv(self.stateDirEnv) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return URL(fileURLWithPath: value, isDirectory: true)
}
}
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("clawdbot", isDirectory: true)
}
return FileManager.default.temporaryDirectory.appendingPathComponent("clawdbot", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
public static func loadOrCreate() -> DeviceIdentity {
let url = self.fileURL()
if let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
!decoded.deviceId.isEmpty,
!decoded.publicKey.isEmpty,
!decoded.privateKey.isEmpty {
return decoded
}
let identity = self.generate()
self.save(identity)
return identity
}
public static func signPayload(_ payload: String, identity: DeviceIdentity) -> String? {
guard let privateKeyData = Data(base64Encoded: identity.privateKey) else { return nil }
do {
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
let signature = try privateKey.signature(for: Data(payload.utf8))
return self.base64UrlEncode(signature)
} catch {
return nil
}
}
private static func generate() -> DeviceIdentity {
let privateKey = Curve25519.Signing.PrivateKey()
let publicKey = privateKey.publicKey
let publicKeyData = publicKey.rawRepresentation
let privateKeyData = privateKey.rawRepresentation
let deviceId = SHA256.hash(data: publicKeyData).compactMap { String(format: "%02x", $0) }.joined()
return DeviceIdentity(
deviceId: deviceId,
publicKey: publicKeyData.base64EncodedString(),
privateKey: privateKeyData.base64EncodedString(),
createdAtMs: Int(Date().timeIntervalSince1970 * 1000))
}
private static func base64UrlEncode(_ data: Data) -> String {
let base64 = data.base64EncodedString()
return base64
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
public static func publicKeyBase64Url(_ identity: DeviceIdentity) -> String? {
guard let data = Data(base64Encoded: identity.publicKey) else { return nil }
return self.base64UrlEncode(data)
}
private static func save(_ identity: DeviceIdentity) {
let url = self.fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try JSONEncoder().encode(identity)
try data.write(to: url, options: [.atomic])
} catch {
// best-effort only
}
}
private static func fileURL() -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
}