more
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
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
|
||||
@@ -16,23 +23,161 @@ public final class NodeIdentity: @unchecked Sendable {
|
||||
agreementPublicKey: agreementKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
public static func loadOrCreate(at url: URL) throws -> NodeIdentity {
|
||||
struct Stored: Codable { let version: Int; let signing: Data; let agreement: Data }
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: url) {
|
||||
let value = try decoder.decode(Stored.self, from: data)
|
||||
guard value.version == currentVersion else { throw UMNError.invalidIdentity }
|
||||
return try NodeIdentity(signingKey: .init(rawRepresentation: value.signing),
|
||||
agreementKey: .init(rawRepresentation: value.agreement))
|
||||
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()
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
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(Stored(version: currentVersion,
|
||||
signing: identity.signingKey.rawRepresentation,
|
||||
agreement: identity.agreementKey.rawRepresentation))
|
||||
try data.write(to: url, options: .atomic)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ func check(_ condition: @autoclosure () throws -> Bool, _ name: String) throws {
|
||||
passed += 1; print("ok \(passed) - \(name)")
|
||||
}
|
||||
|
||||
func rejects(_ name: String, _ operation: () throws -> Void) throws {
|
||||
do { try operation() } catch {
|
||||
passed += 1; print("ok \(passed) - \(name)")
|
||||
return
|
||||
}
|
||||
throw TestFailure(name)
|
||||
}
|
||||
|
||||
func ipv6Packet(source: MeshAddress, destination: MeshAddress, next: UInt8, payload: Data) -> Data {
|
||||
var packet = Data(repeating: 0, count: 40)
|
||||
packet[0] = 0x60; packet[4] = UInt8(payload.count >> 8); packet[5] = UInt8(payload.count & 255)
|
||||
@@ -47,11 +55,84 @@ do {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let identityURL = directory.appendingPathComponent("identity.plist")
|
||||
let stored1 = try NodeIdentity.loadOrCreate(at: identityURL)
|
||||
let stored2 = try NodeIdentity.loadOrCreate(at: identityURL)
|
||||
try check(stored1.record == stored2.record, "identity persists")
|
||||
let addressURL = directory.appendingPathComponent("address")
|
||||
let stored1 = try NodeIdentity.loadOrCreate(in: directory)
|
||||
let stored2 = try NodeIdentity.loadOrCreate(in: directory)
|
||||
try check(stored1.record == stored2.record, "identity and address persist across daemon-style reloads")
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: identityURL.path)
|
||||
try check((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "identity permissions")
|
||||
let addressAttributes = try FileManager.default.attributesOfItem(atPath: addressURL.path)
|
||||
try check((addressAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o600, "address permissions")
|
||||
let storedAddressText = try String(contentsOf: addressURL, encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try check(try MeshAddress(storedAddressText) == stored1.record.address, "stored address is valid IPv6 matching identity")
|
||||
|
||||
let legacyDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: legacyDirectory) }
|
||||
let legacyIdentity = try NodeIdentity.loadOrCreate(at: legacyDirectory.appendingPathComponent("identity.plist"))
|
||||
try check(!FileManager.default.fileExists(atPath: legacyDirectory.appendingPathComponent("address").path),
|
||||
"legacy path API creates only identity")
|
||||
let upgradedIdentity = try NodeIdentity.loadOrCreate(in: legacyDirectory)
|
||||
try check(upgradedIdentity.record == legacyIdentity.record &&
|
||||
FileManager.default.fileExists(atPath: legacyDirectory.appendingPathComponent("address").path),
|
||||
"existing installation gains address sidecar without identity change")
|
||||
|
||||
let corruptDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: corruptDirectory) }
|
||||
try FileManager.default.createDirectory(at: corruptDirectory, withIntermediateDirectories: true)
|
||||
let corruptURL = corruptDirectory.appendingPathComponent("identity.plist")
|
||||
let corruptBytes = Data("not an identity".utf8)
|
||||
try corruptBytes.write(to: corruptURL)
|
||||
try rejects("corrupt identity fails without replacement") { _ = try NodeIdentity.loadOrCreate(in: corruptDirectory) }
|
||||
try check(try Data(contentsOf: corruptURL) == corruptBytes, "corrupt identity remains unchanged")
|
||||
|
||||
let incompatibleDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: incompatibleDirectory) }
|
||||
try FileManager.default.createDirectory(at: incompatibleDirectory, withIntermediateDirectories: true)
|
||||
let incompatibleURL = incompatibleDirectory.appendingPathComponent("identity.plist")
|
||||
let incompatibleBytes = try PropertyListSerialization.data(
|
||||
fromPropertyList: ["version": 999, "signing": Data(repeating: 1, count: 32),
|
||||
"agreement": Data(repeating: 2, count: 32)], format: .binary, options: 0)
|
||||
try incompatibleBytes.write(to: incompatibleURL)
|
||||
try rejects("incompatible identity version fails without replacement") {
|
||||
_ = try NodeIdentity.loadOrCreate(in: incompatibleDirectory)
|
||||
}
|
||||
try check(try Data(contentsOf: incompatibleURL) == incompatibleBytes, "incompatible identity remains unchanged")
|
||||
|
||||
let orphanDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: orphanDirectory) }
|
||||
try FileManager.default.createDirectory(at: orphanDirectory, withIntermediateDirectories: true)
|
||||
let orphanAddressURL = orphanDirectory.appendingPathComponent("address")
|
||||
let orphanBytes = Data("\(alice.record.address)\n".utf8)
|
||||
try orphanBytes.write(to: orphanAddressURL)
|
||||
try rejects("address without identity fails without creating identity") {
|
||||
_ = try NodeIdentity.loadOrCreate(in: orphanDirectory)
|
||||
}
|
||||
try check(!FileManager.default.fileExists(atPath: orphanDirectory.appendingPathComponent("identity.plist").path) &&
|
||||
(try Data(contentsOf: orphanAddressURL)) == orphanBytes, "orphan address state remains unchanged")
|
||||
|
||||
let mismatchDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: mismatchDirectory) }
|
||||
let mismatchIdentity = try NodeIdentity.loadOrCreate(in: mismatchDirectory)
|
||||
let mismatchAddressURL = mismatchDirectory.appendingPathComponent("address")
|
||||
let mismatchedBytes = Data("\(bob.record.address)\n".utf8)
|
||||
try mismatchedBytes.write(to: mismatchAddressURL, options: .atomic)
|
||||
try rejects("address and identity mismatch fails") { _ = try NodeIdentity.loadOrCreate(in: mismatchDirectory) }
|
||||
try check(try Data(contentsOf: mismatchAddressURL) == mismatchedBytes &&
|
||||
mismatchIdentity.record.address != bob.record.address, "mismatched address remains unchanged")
|
||||
|
||||
let unreadableDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: unreadableDirectory) }
|
||||
_ = try NodeIdentity.loadOrCreate(in: unreadableDirectory)
|
||||
let unreadableURL = unreadableDirectory.appendingPathComponent("identity.plist")
|
||||
let unreadableBytes = try Data(contentsOf: unreadableURL)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadableURL.path)
|
||||
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadableURL.path) }
|
||||
try rejects("unreadable identity fails without replacement") {
|
||||
_ = try NodeIdentity.loadOrCreate(in: unreadableDirectory)
|
||||
}
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: unreadableURL.path)
|
||||
try check(try Data(contentsOf: unreadableURL) == unreadableBytes, "unreadable identity remains unchanged")
|
||||
|
||||
let original = InnerFrame(kind: .text, port: 7000, payload: Data("hello".utf8), sourceRecord: alice.record)
|
||||
let sealed = try alice.seal(original, to: bob.record)
|
||||
@@ -76,6 +157,8 @@ do {
|
||||
try check(!router.ingest(try carol.makeLinkState(sequence: 1, neighbors: [])), "stale topology rejected")
|
||||
let forged = LinkState(origin: carol.record, sequence: 2, neighbors: [], signature: Data(repeating: 0, count: 64))
|
||||
try check(!router.ingest(forged), "forged topology rejected")
|
||||
try check((try NodeIdentity.loadOrCreate(in: directory)).record.address == stored1.record.address,
|
||||
"route and peer topology changes do not alter persistent local address")
|
||||
|
||||
let firewall = MeshFirewall()
|
||||
try check(!firewall.allows(port: 80, source: alice.record.address), "firewall defaults to deny")
|
||||
|
||||
@@ -53,7 +53,7 @@ final class MeshDaemon {
|
||||
init(baseURL: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/UltraMesh"), socketPath: String? = nil) throws {
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
identity = try NodeIdentity.loadOrCreate(at: baseURL.appendingPathComponent("identity.plist"))
|
||||
identity = try NodeIdentity.loadOrCreate(in: baseURL)
|
||||
router = LinkStateRouter(local: identity.record.address)
|
||||
configURL = baseURL.appendingPathComponent("config.json")
|
||||
config = DaemonConfig.load(from: configURL)
|
||||
|
||||
Reference in New Issue
Block a user