From f3a9576e38db293bde4b05b74400871b90949896 Mon Sep 17 00:00:00 2001 From: Hank Bob Date: Mon, 4 May 2026 13:09:00 +0700 Subject: [PATCH] fix: tighten Swift CLI command surface --- provider-swift/README.md | 2 +- .../Security/AttestationBuilder.swift | 11 ++++ .../darkbloom-enclave-cli/EnclaveCLI.swift | 21 +++++--- .../Sources/darkbloom/Darkbloom.swift | 3 +- .../Sources/darkbloom/ServeCommand.swift | 51 +++++++++++++++++++ provider-swift/Sources/darkbloom/main.swift | 27 ++++++++++ .../ProviderCoreTests/SecurityTests.swift | 10 ++++ 7 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 provider-swift/Sources/darkbloom/ServeCommand.swift create mode 100644 provider-swift/Sources/darkbloom/main.swift diff --git a/provider-swift/README.md b/provider-swift/README.md index 397aeef7..7dd55f9b 100644 --- a/provider-swift/README.md +++ b/provider-swift/README.md @@ -39,7 +39,7 @@ cp /tmp/mlxvenv/lib/python*/site-packages/mlx/lib/mlx.metallib \ .build/release/mlx.metallib # Then: -.build/release/darkbloom serve --foreground +.build/release/darkbloom serve --model mlx-community/Qwen3-4B-4bit ``` `release-swift.yml` in CI does the same thing automatically and bakes the metallib into the released bundle next to `darkbloom`. diff --git a/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift index 5170c4c2..437512b7 100644 --- a/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift +++ b/provider-swift/Sources/ProviderCore/Security/AttestationBuilder.swift @@ -69,6 +69,17 @@ public struct SignedAttestation: Codable, Sendable { public let signature: String // base64 DER-encoded ECDSA signature } +public enum AttestationInputValidator { + public static let x25519PublicKeyByteCount = 32 + + public static func isValidX25519PublicKeyBase64(_ value: String) -> Bool { + guard let data = Data(base64Encoded: value, options: [.ignoreUnknownCharacters]) else { + return false + } + return data.count == x25519PublicKeyByteCount + } +} + /// Fields covered by `status_signature` in an attestation challenge response. public struct StatusCanonicalInput: Sendable, Equatable { public var nonce: String diff --git a/provider-swift/Sources/darkbloom-enclave-cli/EnclaveCLI.swift b/provider-swift/Sources/darkbloom-enclave-cli/EnclaveCLI.swift index a8912d91..abcea49a 100644 --- a/provider-swift/Sources/darkbloom-enclave-cli/EnclaveCLI.swift +++ b/provider-swift/Sources/darkbloom-enclave-cli/EnclaveCLI.swift @@ -7,7 +7,7 @@ // attest --pub-key Build a signed attestation blob and print JSON. // sign --message Sign a message with the SE key (base64 DER sig). // info Print public key info (base64 + hex). -// wallet-address Print the deterministic identifier derived from the SE key. +// wallet-address Print an ephemeral identifier derived from the SE key. // // All operations create a fresh, ephemeral Secure Enclave key pair. The // tool is stateless: there is no on-disk key material. @@ -34,11 +34,14 @@ struct DarkbloomEnclave: ParsableCommand { private enum EnclaveCLIError: Error, CustomStringConvertible { case secureEnclaveUnavailable + case invalidPublicKey(String) var description: String { switch self { case .secureEnclaveUnavailable: return "Secure Enclave is unavailable on this device (Intel Mac or non-Apple hardware?)" + case .invalidPublicKey(let value): + return "--pub-key must be a base64-encoded 32-byte X25519 public key (got '\(value)')" } } } @@ -64,6 +67,10 @@ struct Attest: ParsableCommand { var binaryHash: String? func run() throws { + if let pubKey, !AttestationInputValidator.isValidX25519PublicKeyBase64(pubKey) { + throw EnclaveCLIError.invalidPublicKey(pubKey) + } + let identity = try loadIdentity() let builder = AttestationBuilder(identity: identity) let data = try builder.buildAttestationJSON( @@ -119,14 +126,16 @@ struct Info: ParsableCommand { struct WalletAddress: ParsableCommand { static let configuration = CommandConfiguration( - abstract: "Print a stable identifier (0x-prefixed 20-byte hex) derived from the SE public key." + abstract: "Print an ephemeral identifier (0x-prefixed 20-byte hex) derived from a fresh SE public key.", + discussion: "The helper is stateless and creates a new Secure Enclave key for each invocation; this value is not durable identity." ) func run() throws { - // The SE produces P-256 keys, not secp256k1; we expose a stable - // identifier derived from the SE pubkey rather than an Ethereum - // address. Coordinators that previously used Ethereum-style hex - // wallets accept any 20-byte hex prefixed with "0x". + // The SE produces P-256 keys, not secp256k1; this stateless helper + // creates a fresh key on every invocation, so the printed value is + // only an ephemeral diagnostic identifier. Coordinators that previously + // used Ethereum-style hex wallets accept any 20-byte hex prefixed with + // "0x". let identity = try loadIdentity() let pubKeyHash = SHA256.hash(data: identity.publicKey.rawRepresentation) let last20 = Array(pubKeyHash).suffix(20) diff --git a/provider-swift/Sources/darkbloom/Darkbloom.swift b/provider-swift/Sources/darkbloom/Darkbloom.swift index 5762493b..bf4c46ca 100644 --- a/provider-swift/Sources/darkbloom/Darkbloom.swift +++ b/provider-swift/Sources/darkbloom/Darkbloom.swift @@ -2,7 +2,7 @@ import Foundation import ArgumentParser import ProviderCore -@main +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) struct Darkbloom: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "darkbloom", @@ -10,6 +10,7 @@ struct Darkbloom: AsyncParsableCommand { discussion: "Runs on Apple Silicon Macs. Connects to the coordinator, serves inference requests via mlx-swift.", version: ProviderCore.version, subcommands: [ + Serve.self, Start.self, Stop.self, Status.self, diff --git a/provider-swift/Sources/darkbloom/ServeCommand.swift b/provider-swift/Sources/darkbloom/ServeCommand.swift new file mode 100644 index 00000000..e09fe9fa --- /dev/null +++ b/provider-swift/Sources/darkbloom/ServeCommand.swift @@ -0,0 +1,51 @@ +import Foundation +import ArgumentParser +import ProviderCore + +@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) +struct Serve: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Run the provider in the foreground.", + discussion: """ + Foreground provider mode for operators and local debugging. This connects + to the coordinator and keeps running until interrupted. Use `darkbloom start` + for the launchd-managed background service. + """ + ) + + @OptionGroup var configOptions: ConfigOptions + + @Option(help: "Override coordinator WebSocket URL.") + var coordinatorURL: String? + + @Option(help: "Model ID to serve (repeatable).") + var model: [String] = [] + + @Flag(help: "Serve all local models.") + var all = false + + @Option(help: "Idle timeout in minutes before unloading the model.") + var idleTimeout: UInt64? + + mutating func run() async throws { + var args: [String] = ["--foreground"] + if let config = configOptions.config { + args += ["--config", config] + } + if let coordinatorURL { + args += ["--coordinator-url", coordinatorURL] + } + for modelID in model { + args += ["--model", modelID] + } + if all { + args.append("--all") + } + if let idleTimeout { + args += ["--idle-timeout", "\(idleTimeout)"] + } + + var start = try Start.parse(args) + try await start.run() + } +} diff --git a/provider-swift/Sources/darkbloom/main.swift b/provider-swift/Sources/darkbloom/main.swift new file mode 100644 index 00000000..f6fe9ed0 --- /dev/null +++ b/provider-swift/Sources/darkbloom/main.swift @@ -0,0 +1,27 @@ +import ArgumentParser +import Foundation + +let arguments = Array(CommandLine.arguments.dropFirst()) +let knownSubcommands = Set(Darkbloom.configuration.subcommands.flatMap { command -> [String] in + [command._commandName] + command.configuration.aliases +}) + +if let first = arguments.first, + !first.hasPrefix("-"), + first != "help", + !knownSubcommands.contains(first) +{ + FileHandle.standardError.write(Data("Error: Unknown subcommand \"\(first)\"\n".utf8)) + Foundation.exit(64) +} + +do { + var command = try Darkbloom.parseAsRoot(arguments) + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } +} catch { + Darkbloom.exit(withError: error) +} diff --git a/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift b/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift index ee159f76..242b9a87 100644 --- a/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift +++ b/provider-swift/Tests/ProviderCoreTests/SecurityTests.swift @@ -120,6 +120,16 @@ import Testing #expect(report.coordinatorRuntimeHashes.templateHashes == report.templateHashes) } +@Test func attestationInputValidatorRequiresBase64X25519PublicKey() throws { + let valid = Data(repeating: 0x42, count: AttestationInputValidator.x25519PublicKeyByteCount) + .base64EncodedString() + + #expect(AttestationInputValidator.isValidX25519PublicKeyBase64(valid)) + #expect(!AttestationInputValidator.isValidX25519PublicKeyBase64("not-base64")) + #expect(!AttestationInputValidator.isValidX25519PublicKeyBase64(Data(repeating: 0x42, count: 31).base64EncodedString())) + #expect(!AttestationInputValidator.isValidX25519PublicKeyBase64(Data(repeating: 0x42, count: 33).base64EncodedString())) +} + @Test func statusCanonicalMatchesCoordinatorGoldenBytes() throws { let data = try StatusCanonical.build(StatusCanonicalInput( nonce: "test-nonce",