diff --git a/README.md b/README.md index aad0d92..ff2678f 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,15 @@ umn status umn address ``` -The installer builds locally, asks for administrator authorization once, installs a minimal root LaunchDaemon for `utun` and `/128` route management, installs the scoped `.mesh` resolver, then starts `umnd` as a per-user LaunchAgent. Peer networking, keys, packet parsing, firewall state, and DNS remain in the unprivileged daemon. If prompted, allow local-network access. Identity and configuration live in `~/Library/Application Support/UltraMesh`. +The installer builds locally, asks for administrator authorization once, installs a minimal root LaunchDaemon for `utun` and `/128` route management, installs the scoped `.mesh` resolver, then starts `umnd` as a per-user LaunchAgent. Peer networking, keys, packet parsing, firewall state, and DNS remain in the unprivileged daemon. If prompted, allow local-network access. All persistent state lives in `~/Library/Application Support/UltraMesh`. The cryptographic identity in `identity.plist` determines the machine's mesh IPv6, and the same value is recorded as plain text in `address`; both files are mode `0600`. -Use `./scripts/uninstall.sh` to remove both daemons, the interface/routes, binaries, and installer-managed resolver. It deliberately preserves identity, aliases, and firewall configuration so reinstalling keeps the same mesh address. +Use `./scripts/uninstall.sh` to remove both daemons, the interface/routes, binaries, and installer-managed resolver. It deliberately preserves the entire state directory so reinstalling keeps the same mesh address. Do not edit or separately replace `identity.plist` or `address`: if either is corrupt, missing from an established identity/address pair, or mismatched, startup fails instead of silently assigning a new address. + +To verify the persistent address after installation or a restart: + +```sh +test "$(umn address)" = "$(tr -d '[:space:]' < "$HOME/Library/Application Support/UltraMesh/address")" +``` ## Native IPv6 @@ -96,6 +102,7 @@ HTTPS is passed through unchanged. The web server remains responsible for its TL ## Design and current limits - Bonjour and Network.framework discover peers over infrastructure and Apple peer-to-peer Wi-Fi (`includePeerToPeer`). New routes recover automatically when an access-point path disappears while Wi-Fi remains enabled. +- The local mesh IPv6 is derived only from the persistent signing identity. Wi-Fi path changes, peer-to-peer fallback, route updates, `utun` recreation, reboots, and reinstalls do not select or alter it. - Signed link-state announcements and shortest-hop routing support multi-hop topologies. The implementation is bounded and tested for 32 live nodes and 16 hops. - Service payloads are authenticated and encrypted end-to-end with Curve25519, HKDF-SHA256, and ChaCha20-Poly1305. Relays see routing metadata but cannot read ports or content. - Streams use sequence numbers, acknowledgements, retransmission, and a 60-second recovery window. An active stream can continue after a route change if another path appears within that window. diff --git a/Sources/UltraMeshCore/Crypto.swift b/Sources/UltraMeshCore/Crypto.swift index 9f32382..257b28d 100644 --- a/Sources/UltraMeshCore/Crypto.swift +++ b/Sources/UltraMeshCore/Crypto.swift @@ -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 } diff --git a/Sources/umn-selftest/main.swift b/Sources/umn-selftest/main.swift index e288bcf..f4fb7e6 100644 --- a/Sources/umn-selftest/main.swift +++ b/Sources/umn-selftest/main.swift @@ -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") diff --git a/Sources/umnd/MeshDaemon.swift b/Sources/umnd/MeshDaemon.swift index 1dffd86..a5a1f19 100644 --- a/Sources/umnd/MeshDaemon.swift +++ b/Sources/umnd/MeshDaemon.swift @@ -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) diff --git a/scripts/install.sh b/scripts/install.sh index 9accb3f..31518e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -82,5 +82,8 @@ STATUS=$("${BIN_DIR}/umn" status) echo "${STATUS}" INTERFACE=$("${BIN_DIR}/umn" interface status) echo "${INTERFACE}" +ADDRESS=$("${BIN_DIR}/umn" address) +STORED_ADDRESS=$(tr -d '[:space:]' < "${STATE_DIR}/address") +[[ "${ADDRESS}" == "${STORED_ADDRESS}" ]] || { echo "identity address health check failed: umn address does not match ${STATE_DIR}/address." >&2; exit 1; } [[ "${STATUS}" == *"DNS listening"* ]] || { echo "DNS health check failed (port 53535 may be busy)." >&2; exit 1; } [[ "${INTERFACE}" == *"helper: connected"* ]] || { echo "native interface health check failed." >&2; exit 1; } diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 6198e28..059294f 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -26,4 +26,4 @@ sudo launchctl bootout system "${HELPER_PLIST}" >/dev/null 2>&1 || true if [[ -e "${RESOLVER_PATH}" ]] && cmp -s "${TEMP_RESOLVER}" "${RESOLVER_PATH}"; then sudo unlink "${RESOLVER_PATH}"; fi echo "Binaries, LaunchAgent, LaunchDaemon, routes, interface, and managed resolver removed." -echo "Identity and configuration were preserved in ~/Library/Application Support/UltraMesh." +echo "All persistent state, including identity and address, was preserved in ~/Library/Application Support/UltraMesh."