Files
umn/Sources/UltraMeshCore/Crypto.swift
T
2026-08-31 17:21:07 -05:00

225 lines
12 KiB
Swift

import Foundation
import CryptoKit
import Darwin
public final class NodeIdentity: @unchecked Sendable {
private struct StoredIdentity: Codable {
let version: Int
let signing: Data
let agreement: Data
}
public static let currentVersion = 1
public let signingKey: Curve25519.Signing.PrivateKey
public let agreementKey: Curve25519.KeyAgreement.PrivateKey
public let record: NodeRecord
public init(signingKey: Curve25519.Signing.PrivateKey = .init(),
agreementKey: Curve25519.KeyAgreement.PrivateKey = .init()) throws {
self.signingKey = signingKey; self.agreementKey = agreementKey
let signing = signingKey.publicKey.rawRepresentation
self.record = NodeRecord(address: try MeshAddress.derive(from: signing),
signingPublicKey: signing,
agreementPublicKey: agreementKey.publicKey.rawRepresentation)
}
private static func writeAtomicallyWithoutReplacing(_ data: Data, to url: URL) throws {
let temporaryURL = url.deletingLastPathComponent()
.appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).tmp")
let descriptor = Darwin.open(temporaryURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
guard descriptor >= 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
defer {
Darwin.close(descriptor)
Darwin.unlink(temporaryURL.path)
}
try data.withUnsafeBytes { bytes in
guard let base = bytes.baseAddress else { return }
var offset = 0
while offset < data.count {
let written = Darwin.write(descriptor, base.advanced(by: offset), data.count - offset)
if written < 0 {
if errno == EINTR { continue }
throw POSIXError(.init(rawValue: errno) ?? .EIO)
}
offset += written
}
}
guard Darwin.fsync(descriptor) == 0 else { throw POSIXError(.init(rawValue: errno) ?? .EIO) }
// A hard link publishes the fully-written same-filesystem temporary file in
// one operation and fails with EEXIST instead of replacing existing state.
guard Darwin.link(temporaryURL.path, url.path) == 0 else {
throw POSIXError(.init(rawValue: errno) ?? .EIO)
}
}
private static func itemExists(at url: URL) throws -> Bool {
var status = stat()
if Darwin.lstat(url.path, &status) == 0 { return true }
let code = errno
if code == ENOENT || code == ENOTDIR { return false }
throw POSIXError(.init(rawValue: code) ?? .EIO)
}
public static func loadOrCreate(at url: URL) throws -> NodeIdentity {
let fileManager = FileManager.default
func load() throws -> NodeIdentity {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw UMNError.message("Cannot read the UltraMesh identity at \(url.path). Restore access to this file; it was not replaced.")
}
let value: StoredIdentity
do {
value = try PropertyListDecoder().decode(StoredIdentity.self, from: data)
} catch {
throw UMNError.message("The UltraMesh identity at \(url.path) is corrupt. Restore it from backup or explicitly reset UltraMesh state; it was not replaced.")
}
guard value.version == currentVersion else {
throw UMNError.message("The UltraMesh identity at \(url.path) uses unsupported version \(value.version). Upgrade UltraMesh or restore a compatible identity; it was not replaced.")
}
do {
return try NodeIdentity(signingKey: .init(rawRepresentation: value.signing),
agreementKey: .init(rawRepresentation: value.agreement))
} catch {
throw UMNError.message("The UltraMesh identity at \(url.path) contains invalid cryptographic keys. Restore it from backup or explicitly reset UltraMesh state; it was not replaced.")
}
}
let exists: Bool
do {
exists = try itemExists(at: url)
} catch {
throw UMNError.message("Cannot inspect the UltraMesh identity path at \(url.path): \(error.localizedDescription). No new identity was created.")
}
if exists { return try load() }
let identity = try NodeIdentity()
do {
try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
} catch {
throw UMNError.message("Cannot create the UltraMesh state directory at \(url.deletingLastPathComponent().path): \(error.localizedDescription)")
}
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
let data = try encoder.encode(StoredIdentity(version: currentVersion,
signing: identity.signingKey.rawRepresentation,
agreement: identity.agreementKey.rawRepresentation))
do {
try writeAtomicallyWithoutReplacing(data, to: url)
} catch {
// Another daemon may have won the first-run race. Its complete identity is
// authoritative; never overwrite it with the identity generated above.
if (try? itemExists(at: url)) == true { return try load() }
throw UMNError.message("Cannot create the UltraMesh identity at \(url.path): \(error.localizedDescription)")
}
do {
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
throw UMNError.message("The UltraMesh identity was created at \(url.path), but its permissions could not be restricted to 0600: \(error.localizedDescription)")
}
return identity
}
/// Loads the persistent identity in a state directory and creates or verifies
/// its derived, human-readable address sidecar.
public static func loadOrCreate(in directory: URL) throws -> NodeIdentity {
let fileManager = FileManager.default
let identityURL = directory.appendingPathComponent("identity.plist")
let addressURL = directory.appendingPathComponent("address")
let identityExists: Bool
let addressExists: Bool
do {
identityExists = try itemExists(at: identityURL)
addressExists = try itemExists(at: addressURL)
} catch {
throw UMNError.message("Cannot inspect UltraMesh state in \(directory.path): \(error.localizedDescription). No state was changed.")
}
guard identityExists || !addressExists else {
throw UMNError.message("Found \(addressURL.path) without \(identityURL.path). Restore the matching identity or explicitly reset UltraMesh state; no new identity was created.")
}
let identity = try loadOrCreate(at: identityURL)
func verifyAddress() throws {
let data: Data
do {
data = try Data(contentsOf: addressURL)
} catch {
throw UMNError.message("Cannot read the stored UltraMesh address at \(addressURL.path). Restore access to this file; it was not replaced.")
}
guard let stored = String(data: data, encoding: .utf8) else {
throw UMNError.message("The stored UltraMesh address at \(addressURL.path) is not UTF-8 text. Restore the matching record or explicitly reset UltraMesh state; it was not replaced.")
}
let text = stored.trimmingCharacters(in: .whitespacesAndNewlines)
guard let address = try? MeshAddress(text), address == identity.record.address else {
throw UMNError.message("The stored UltraMesh address at \(addressURL.path) does not match the persistent identity. Restore the matching identity/address pair or explicitly reset UltraMesh state; neither file was replaced.")
}
}
if addressExists {
try verifyAddress()
} else {
let data = Data("\(identity.record.address)\n".utf8)
do {
try writeAtomicallyWithoutReplacing(data, to: addressURL)
} catch {
// As with identity creation, tolerate only a concurrent complete write.
if (try? itemExists(at: addressURL)) == true { try verifyAddress() }
else {
throw UMNError.message("Cannot create the stored UltraMesh address at \(addressURL.path): \(error.localizedDescription)")
}
}
}
do {
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: addressURL.path)
} catch {
throw UMNError.message("Cannot restrict the stored UltraMesh address at \(addressURL.path) to mode 0600: \(error.localizedDescription)")
}
return identity
}
public func sign(_ data: Data) throws -> Data { try signingKey.signature(for: data) }
public func makeLinkState(sequence: UInt64, neighbors: [MeshAddress]) throws -> LinkState {
let unsigned = LinkState(origin: record, sequence: sequence, neighbors: neighbors, signature: Data())
return LinkState(origin: record, sequence: sequence, neighbors: neighbors,
signature: try sign(unsigned.signingBytes()))
}
public func seal(_ inner: InnerFrame, to destination: NodeRecord) throws -> SealedPayload {
guard destination.validate() else { throw UMNError.invalidIdentity }
let encoder = PropertyListEncoder(); encoder.outputFormat = .binary
let innerData = try encoder.encode(inner)
struct Signed: Codable { let inner: Data; let signature: Data }
let signed = try encoder.encode(Signed(inner: innerData, signature: sign(innerData)))
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let remote = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: destination.agreementPublicKey)
let secret = try ephemeral.sharedSecretFromKeyAgreement(with: remote)
let key = secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data("umn-e2e-v1".utf8),
sharedInfo: destination.address.bytes, outputByteCount: 32)
let box = try ChaChaPoly.seal(signed, using: key)
return SealedPayload(ephemeralPublicKey: ephemeral.publicKey.rawRepresentation,
combinedCiphertext: box.combined)
}
public func open(_ payload: SealedPayload, expectedSource: MeshAddress) throws -> InnerFrame {
struct Signed: Codable { let inner: Data; let signature: Data }
let ephemeral = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: payload.ephemeralPublicKey)
let secret = try agreementKey.sharedSecretFromKeyAgreement(with: ephemeral)
let key = secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: Data("umn-e2e-v1".utf8),
sharedInfo: record.address.bytes, outputByteCount: 32)
let box = try ChaChaPoly.SealedBox(combined: payload.combinedCiphertext)
let data = try ChaChaPoly.open(box, using: key)
let decoder = PropertyListDecoder()
let signed = try decoder.decode(Signed.self, from: data)
let inner = try decoder.decode(InnerFrame.self, from: signed.inner)
guard inner.sourceRecord.address == expectedSource, inner.sourceRecord.validate() else { throw UMNError.invalidIdentity }
let keyVerify = try Curve25519.Signing.PublicKey(rawRepresentation: inner.sourceRecord.signingPublicKey)
guard keyVerify.isValidSignature(signed.signature, for: signed.inner) else { throw UMNError.invalidIdentity }
return inner
}
}