225 lines
12 KiB
Swift
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
|
|
}
|
|
}
|